forked from boos/BoosAPI
feat: rebrand Metapi to BoosAPI, add ComfyUI agent and user management
- Rename project from Metapi to BoosAPI across all UI and server strings - Add ComfyUI conversational agent page (/comfyui-agent) - Add user management system (register, login, API keys, admin) - Update Dockerfile and database schema - Add ComfyUI workflow nodes support - Update shared contracts and platform configurations Signed-off-by: Boos4721 <boos4721@icloud.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const registerUserSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
email: z.string().email().max(256),
|
||||
password: z.string().min(6).max(128),
|
||||
});
|
||||
|
||||
export const loginUserSchema = z.object({
|
||||
email: z.string().email().max(256),
|
||||
password: z.string().min(1).max(128),
|
||||
});
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
username: z.string().min(1).max(64).optional(),
|
||||
role: z.enum(['admin', 'user']).optional(),
|
||||
status: z.enum(['active', 'disabled']).optional(),
|
||||
});
|
||||
|
||||
export const updatePasswordSchema = z.object({
|
||||
oldPassword: z.string().min(1).max(128),
|
||||
newPassword: z.string().min(6).max(128),
|
||||
});
|
||||
@@ -43,6 +43,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([
|
||||
'downstream_api_keys',
|
||||
'site_announcements',
|
||||
'events',
|
||||
'users',
|
||||
]);
|
||||
|
||||
export let runtimeDbDialect: RuntimeDbDialect = config.dbType;
|
||||
|
||||
@@ -508,6 +508,7 @@ export const downstreamApiKeys = sqliteTable('downstream_api_keys', {
|
||||
siteWeightMultipliers: text('site_weight_multipliers'), // JSON object { [siteId]: multiplier }
|
||||
excludedSiteIds: text('excluded_site_ids'), // JSON array<number>
|
||||
excludedCredentialRefs: text('excluded_credential_refs'), // JSON array<DownstreamExcludedCredentialRef>
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
lastUsedAt: text('last_used_at'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
@@ -542,6 +543,20 @@ export const siteAnnouncements = sqliteTable('site_announcements', {
|
||||
readAtIdx: index('site_announcements_read_at_idx').on(table.readAt),
|
||||
}));
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
username: text('username').notNull(),
|
||||
email: text('email').notNull().unique(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
role: text('role').notNull().default('user'),
|
||||
status: text('status').notNull().default('active'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
emailUnique: uniqueIndex('users_email_unique').on(table.email),
|
||||
statusIdx: index('users_status_idx').on(table.status),
|
||||
}));
|
||||
|
||||
export const events = sqliteTable('events', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
type: text('type').notNull(), // 'checkin' | 'balance' | 'token' | 'proxy' | 'status'
|
||||
|
||||
@@ -743,7 +743,7 @@ function applySqliteMigrations(sqlite: Database.Database): void {
|
||||
}
|
||||
|
||||
function createTemporarySqlitePath(): string {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'metapi-schema-parity-'));
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'boosapi-schema-parity-'));
|
||||
return resolve(tempDir, `${randomUUID()}.db`);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { FastifyInstance } from 'fastify';
|
||||
const DESKTOP_HEALTH_ROUTE = '/api/desktop/health';
|
||||
|
||||
export function isPublicApiRoute(url: string): boolean {
|
||||
return url === DESKTOP_HEALTH_ROUTE || url.startsWith('/api/oauth/callback/');
|
||||
return url === DESKTOP_HEALTH_ROUTE
|
||||
|| url.startsWith('/api/oauth/callback/')
|
||||
|| url.startsWith('/api/comfyui/proxy/')
|
||||
// User SaaS routes (JWT auth, not admin token)
|
||||
|| url === '/api/users/register'
|
||||
|| url === '/api/users/login'
|
||||
|| url.startsWith('/api/users/')
|
||||
|| url.startsWith('/api/user-api-keys/');
|
||||
}
|
||||
|
||||
export async function registerDesktopRoutes(app: FastifyInstance) {
|
||||
|
||||
@@ -20,9 +20,15 @@ import { taskRoutes } from './routes/api/tasks.js';
|
||||
import { testRoutes } from './routes/api/test.js';
|
||||
import { monitorRoutes } from './routes/api/monitor.js';
|
||||
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
|
||||
import { usersRoutes } from './routes/api/users.js';
|
||||
import { userApiKeysRoutes } from './routes/api/userApiKeys.js';
|
||||
import { oauthRoutes } from './routes/api/oauth.js';
|
||||
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
|
||||
import { updateCenterRoutes } from './routes/api/updateCenter.js';
|
||||
import { comfyuiRoutes } from './routes/api/comfyui.js';
|
||||
import { videoAgentRoutes } from './routes/api/videoAgent.js';
|
||||
import { adminUsersRoutes } from './routes/api/adminUsers.js';
|
||||
import { comfyuiAgentRoutes } from './routes/api/comfyuiAgent.js';
|
||||
import { proxyRoutes } from './routes/proxy/router.js';
|
||||
import { startScheduler } from './services/checkinScheduler.js';
|
||||
import * as routeRefreshWorkflow from './services/routeRefreshWorkflow.js';
|
||||
@@ -228,6 +234,12 @@ await app.register(taskRoutes);
|
||||
await app.register(testRoutes);
|
||||
await app.register(monitorRoutes);
|
||||
await app.register(downstreamApiKeysRoutes);
|
||||
await app.register(adminUsersRoutes);
|
||||
await app.register(usersRoutes);
|
||||
await app.register(userApiKeysRoutes);
|
||||
await app.register(comfyuiRoutes);
|
||||
await app.register(videoAgentRoutes);
|
||||
await app.register(comfyuiAgentRoutes);
|
||||
await app.register(oauthRoutes);
|
||||
|
||||
// Register OpenAI-compatible proxy routes
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { config } from '../config.js';
|
||||
import { authorizeDownstreamToken, consumeManagedKeyRequest } from '../services/downstreamApiKeyService.js';
|
||||
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../services/downstreamPolicyTypes.js';
|
||||
import { verifyJwt, type JwtPayload } from '../services/userService.js';
|
||||
|
||||
export interface ProxyAuthContext {
|
||||
token: string;
|
||||
@@ -17,7 +18,14 @@ export interface ProxyResourceOwner {
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface UserAuthContext {
|
||||
userId: number;
|
||||
email: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
const proxyAuthContextByRequest = new WeakMap<FastifyRequest, ProxyAuthContext>();
|
||||
const userAuthContextByRequest = new WeakMap<FastifyRequest, UserAuthContext>();
|
||||
|
||||
type ParsedAllowlistEntry =
|
||||
| { kind: 'exact'; normalizedIp: string }
|
||||
@@ -190,3 +198,36 @@ export function getProxyResourceOwner(request: FastifyRequest): ProxyResourceOwn
|
||||
ownerId: 'global',
|
||||
};
|
||||
}
|
||||
|
||||
export async function userAuthMiddleware(request: FastifyRequest, reply: FastifyReply) {
|
||||
const auth = request.headers.authorization;
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
reply.code(401).send({ error: 'Missing or invalid Authorization header' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = auth.slice('Bearer '.length).trim();
|
||||
const payload = verifyJwt(token);
|
||||
if (!payload) {
|
||||
reply.code(401).send({ error: 'Invalid or expired token' });
|
||||
return;
|
||||
}
|
||||
|
||||
userAuthContextByRequest.set(request, {
|
||||
userId: payload.userId,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
});
|
||||
}
|
||||
|
||||
export function getUserAuthContext(request: FastifyRequest): UserAuthContext | null {
|
||||
return userAuthContextByRequest.get(request) || null;
|
||||
}
|
||||
|
||||
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
|
||||
const user = getUserAuthContext(request);
|
||||
if (!user || user.role !== 'admin') {
|
||||
reply.code(403).send({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function listModelsSurface(input: ModelsSurfaceInput) {
|
||||
id,
|
||||
object: 'model' as const,
|
||||
created: Math.floor(now.getTime() / 1000),
|
||||
owned_by: 'metapi',
|
||||
owned_by: 'boosapi',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
getUserById,
|
||||
listUsers,
|
||||
updateUser,
|
||||
type UserView,
|
||||
} from '../../services/userService.js';
|
||||
|
||||
function omitPassword(user: UserView) {
|
||||
const { ...rest } = user;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export async function adminUsersRoutes(app: FastifyInstance) {
|
||||
// List all users
|
||||
app.get('/api/admin/users', async (_request, reply) => {
|
||||
const users = await listUsers();
|
||||
reply.send({ users: users.map(omitPassword) });
|
||||
});
|
||||
|
||||
// Get user by ID
|
||||
app.get('/api/admin/users/:id', async (request, reply) => {
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid user ID' });
|
||||
}
|
||||
const user = await getUserById(id);
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: 'User not found' });
|
||||
}
|
||||
reply.send({ user: omitPassword(user) });
|
||||
});
|
||||
|
||||
// Update user (role, status, username)
|
||||
app.patch('/api/admin/users/:id', async (request, reply) => {
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid user ID' });
|
||||
}
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const updates: Partial<{ username: string; role: string; status: string }> = {};
|
||||
if (body.username !== undefined) updates.username = String(body.username);
|
||||
if (body.role !== undefined) {
|
||||
if (body.role !== 'admin' && body.role !== 'user') {
|
||||
return reply.code(400).send({ error: 'Role must be "admin" or "user"' });
|
||||
}
|
||||
updates.role = body.role;
|
||||
}
|
||||
if (body.status !== undefined) {
|
||||
if (body.status !== 'active' && body.status !== 'disabled') {
|
||||
return reply.code(400).send({ error: 'Status must be "active" or "disabled"' });
|
||||
}
|
||||
updates.status = body.status;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateUser(id, updates);
|
||||
}
|
||||
const user = await getUserById(id);
|
||||
reply.send({ user: user ? omitPassword(user) : null });
|
||||
});
|
||||
|
||||
// Disable/delete user
|
||||
app.delete('/api/admin/users/:id', async (request, reply) => {
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid user ID' });
|
||||
}
|
||||
await updateUser(id, { status: 'disabled' });
|
||||
reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
|
||||
const COMFYUI_BASE = 'http://127.0.0.1:8188';
|
||||
|
||||
/** Rebrand ComfyUI HTML to show BoosAPI instead */
|
||||
function rebrandHtml(html: string): string {
|
||||
return html
|
||||
.replace(/ComfyUI/g, 'BoosAPI')
|
||||
.replace(/comfyui/gi, 'BoosAPI');
|
||||
}
|
||||
|
||||
function rewriteProxyUrls(html: string): string {
|
||||
return html.replace(/(src|href|action)=["']\//g, (m) =>
|
||||
m.replace(/["']/, '/api/comfyui/proxy/')
|
||||
);
|
||||
}
|
||||
|
||||
export async function comfyuiRoutes(app: FastifyInstance) {
|
||||
// Proxy: POST /api/comfyui/prompt -> ComfyUI /prompt
|
||||
app.post('/api/comfyui/prompt', async (request, reply) => {
|
||||
try {
|
||||
const body = typeof request.body === 'object' ? JSON.stringify(request.body) : '{}';
|
||||
const resp = await fetch(`${COMFYUI_BASE}/prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
const text = await resp.text();
|
||||
return reply.code(resp.status).type('application/json').send(text);
|
||||
} catch (e: any) {
|
||||
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy: GET /api/comfyui/history -> ComfyUI /history
|
||||
app.get('/api/comfyui/history', async (_request, reply) => {
|
||||
try {
|
||||
const resp = await fetch(`${COMFYUI_BASE}/history`);
|
||||
const text = await resp.text();
|
||||
return reply.code(resp.status).type('application/json').send(text);
|
||||
} catch (e: any) {
|
||||
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy: GET /api/comfyui/history/:id -> ComfyUI /history/{id}
|
||||
app.get<{ Params: { id: string } }>('/api/comfyui/history/:id', async (request, reply) => {
|
||||
try {
|
||||
const resp = await fetch(`${COMFYUI_BASE}/history/${encodeURIComponent(request.params.id)}`);
|
||||
const text = await resp.text();
|
||||
return reply.code(resp.status).type('application/json').send(text);
|
||||
} catch (e: any) {
|
||||
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy: GET /api/comfyui/queue -> ComfyUI /queue
|
||||
app.get('/api/comfyui/queue', async (_request, reply) => {
|
||||
try {
|
||||
const resp = await fetch(`${COMFYUI_BASE}/queue`);
|
||||
const text = await resp.text();
|
||||
return reply.code(resp.status).type('application/json').send(text);
|
||||
} catch (e: any) {
|
||||
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy: GET /api/comfyui/view -> ComfyUI /view (image output, preserve query params)
|
||||
app.get('/api/comfyui/view', async (request, reply) => {
|
||||
try {
|
||||
const qs = request.url.includes('?') ? request.url.substring(request.url.indexOf('?')) : '';
|
||||
const resp = await fetch(`${COMFYUI_BASE}/view${qs}`);
|
||||
const buffer = await resp.arrayBuffer();
|
||||
return reply.code(resp.status).type(resp.headers.get('content-type') || 'application/octet-stream').send(Buffer.from(buffer));
|
||||
} catch (e: any) {
|
||||
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy: GET /api/comfyui/proxy/* -> proxy everything to ComfyUI (HTML, assets, JS, CSS, etc.)
|
||||
app.get('/api/comfyui/proxy/*', async (request, reply) => {
|
||||
try {
|
||||
const path = request.url.replace('/api/comfyui/proxy', '');
|
||||
const targetUrl = `${COMFYUI_BASE}${path}`;
|
||||
const resp = await fetch(targetUrl);
|
||||
const contentType = resp.headers.get('content-type') || 'application/octet-stream';
|
||||
if (contentType.includes('html')) {
|
||||
const text = await resp.text();
|
||||
const rebranded = rebrandHtml(text);
|
||||
const rewritten = rewriteProxyUrls(rebranded);
|
||||
return reply.code(resp.status).type(contentType).send(rewritten);
|
||||
}
|
||||
if (contentType.includes('text') || contentType.includes('json')) {
|
||||
const text = await resp.text();
|
||||
return reply.code(resp.status).type(contentType).send(text);
|
||||
}
|
||||
const buffer = await resp.arrayBuffer();
|
||||
return reply.code(resp.status).type(contentType).send(Buffer.from(buffer));
|
||||
} catch (e: any) {
|
||||
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve ComfyUI HTML page at /comfyui with iframe to proxied ComfyUI
|
||||
app.get('/comfyui', async (_request, reply) => {
|
||||
return reply.type('text/html').send(`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BoosAPI - ComfyUI</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; }
|
||||
iframe { width: 100%; height: 100%; border: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="/api/comfyui/proxy/" title="BoosAPI ComfyUI"></iframe>
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
|
||||
const BOOSAPI_BASE = `http://127.0.0.1:${process.env.PORT || 3000}`;
|
||||
|
||||
const SYSTEM_PROMPT = `你是一个 AI 视频生成助手,帮助用户通过 ComfyUI 生成 AI 视频。你可以:
|
||||
|
||||
## 能力
|
||||
|
||||
1. **分析脚本** — 提取角色、场景、对话,输出结构化 JSON
|
||||
2. **生成角色提示词** — 为每个角色生成 4 个角度(正面/背面/左侧面/右侧面)的图像生成提示词
|
||||
3. **生成角色图像** — 调用图像生成 API 生成角色各个角度的图像
|
||||
4. **生成场景提示词** — 根据场景描述生成稳定的图像生成提示词
|
||||
5. **生成语音(TTS)** — 将对话台词转为语音
|
||||
6. **导出 ComfyUI 工作流** — 将整个视频项目打包为 ComfyUI 可用的 workflow JSON
|
||||
|
||||
## 输出格式
|
||||
|
||||
你的回复必须是 SSE 格式,每条消息是一个 JSON 对象,以 \`data: \` 开头,以 \n\n 结尾。
|
||||
|
||||
### 文本消息
|
||||
data: {"type":"text","content":"你的回复内容"}
|
||||
|
||||
### 角色提示词
|
||||
data: {"type":"character_prompts","character":"角色名","angles":{"front":"正面提示词","back":"背面提示词","left":"左侧面提示词","right":"右侧面提示词"}}
|
||||
|
||||
### 角色图像
|
||||
data: {"type":"image","url":"图片URL","character":"角色名","angle":"角度"}
|
||||
|
||||
### 场景提示词
|
||||
data: {"type":"scene","id":1,"description":"场景描述","prompt":"图像提示词","negative_prompt":"负面提示词"}
|
||||
|
||||
### TTS
|
||||
data: {"type":"tts","character":"角色名","text":"台词","url":"语音URL","duration_ms":1234}
|
||||
|
||||
### ComfyUI 工作流
|
||||
data: {"type":"comfyui_workflow","json":{...workflow JSON...}}
|
||||
|
||||
### 完成标记
|
||||
data: {"type":"done"}
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 用户提供脚本 → 你分析脚本结构
|
||||
2. 针对每个角色 → 生成 4 角度提示词 → 调用图像生成
|
||||
3. 针对每个场景 → 生成场景提示词 → 调用图像生成
|
||||
4. 针对每段对话 → 生成 TTS 语音
|
||||
5. 最终导出 ComfyUI 工作流 JSON
|
||||
|
||||
始终用中文回复。不要输出 markdown 代码块,直接输出 SSE 格式数据。`;
|
||||
|
||||
interface ChatRequest {
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
async function callLLM(messages: Array<{ role: string; content: string }>, signal?: AbortSignal): Promise<any> {
|
||||
return fetch(`${BOOSAPI_BASE}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer admin',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-5.5',
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
...messages,
|
||||
],
|
||||
temperature: 0.7,
|
||||
stream: true,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function callImageGen(prompt: string): Promise<string> {
|
||||
const resp = await fetch(`${BOOSAPI_BASE}/v1/images/generations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer admin',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'dall-e-3',
|
||||
prompt,
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
}),
|
||||
});
|
||||
const data: any = await resp.json();
|
||||
return data?.data?.[0]?.url || '';
|
||||
}
|
||||
|
||||
export async function comfyuiAgentRoutes(app: FastifyInstance) {
|
||||
app.post('/api/comfyui-agent/chat', async (request, reply) => {
|
||||
const { messages } = request.body as ChatRequest;
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return reply.code(400).send({ error: 'messages is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Hijack the Fastify response for SSE streaming
|
||||
reply.hijack();
|
||||
|
||||
const raw = reply.raw;
|
||||
raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Handle client disconnect
|
||||
raw.on('close', () => {
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
const llmResp = await callLLM(messages, abortController.signal);
|
||||
|
||||
if (!llmResp.ok) {
|
||||
raw.write(`data: ${JSON.stringify({ type: 'text', content: 'LLM 调用失败,请稍后重试' })}\n\n`);
|
||||
raw.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
|
||||
raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!llmResp.body) {
|
||||
raw.write(`data: ${JSON.stringify({ type: 'text', content: 'LLM 返回空响应' })}\n\n`);
|
||||
raw.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
|
||||
raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = llmResp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed?.choices?.[0]?.delta?.content || '';
|
||||
if (content) {
|
||||
// Check if the content contains SSE-formatted data from the agent
|
||||
// If it's a direct SSE data: line, forward it verbatim
|
||||
// Otherwise wrap as text event
|
||||
const sseMatch = content.match(/^data:\s*(\{.*\})/s);
|
||||
if (sseMatch) {
|
||||
try {
|
||||
const sseData = JSON.parse(sseMatch[1]);
|
||||
raw.write(`data: ${JSON.stringify(sseData)}\n\n`);
|
||||
continue;
|
||||
} catch {
|
||||
// Not valid JSON SSE, treat as regular text
|
||||
}
|
||||
}
|
||||
raw.write(`data: ${JSON.stringify({ type: 'text', content })}\n\n`);
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') {
|
||||
raw.write(`data: ${JSON.stringify({ type: 'text', content: '\\n\\n[生成中断]' })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
raw.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
|
||||
raw.end();
|
||||
} catch (e: any) {
|
||||
// If we haven't hijacked yet, return error as JSON
|
||||
const raw = reply.raw;
|
||||
if (!raw.headersSent) {
|
||||
reply.code(500).send({ error: `Chat failed: ${e.message}` });
|
||||
} else {
|
||||
raw.write(`data: ${JSON.stringify({ type: 'text', content: `错误: ${e.message}` })}\n\n`);
|
||||
raw.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
|
||||
raw.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export async function monitorRoutes(app: FastifyInstance) {
|
||||
cookie: storedCookie,
|
||||
accept: String(request.headers.accept || '*/*'),
|
||||
'accept-language': String(request.headers['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8'),
|
||||
'user-agent': String(request.headers['user-agent'] || 'metapiMonitorProxy/1.0'),
|
||||
'user-agent': String(request.headers['user-agent'] || 'boosapiMonitorProxy/1.0'),
|
||||
};
|
||||
if (request.headers['content-type']) {
|
||||
upstreamHeaders['content-type'] = String(request.headers['content-type']);
|
||||
|
||||
@@ -574,7 +574,7 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
});
|
||||
message = 'OAuth authorization succeeded. You can close this window.';
|
||||
} catch {
|
||||
message = 'OAuth authorization failed. Return to metapi and review the server logs.';
|
||||
message = 'OAuth authorization failed. Return to BoosAPI and review the server logs.';
|
||||
}
|
||||
|
||||
reply.type('text/html; charset=utf-8');
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { insertAndGetById } from '../../db/insertHelpers.js';
|
||||
import {
|
||||
getDownstreamApiKeyById,
|
||||
normalizeDownstreamApiKeyPayload,
|
||||
toDownstreamApiKeyPolicyView,
|
||||
toPersistenceJson,
|
||||
} from '../../services/downstreamApiKeyService.js';
|
||||
import { userAuthMiddleware, getUserAuthContext } from '../../middleware/auth.js';
|
||||
|
||||
export async function userApiKeysRoutes(app: FastifyInstance) {
|
||||
app.get('/api/user-api-keys', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
|
||||
|
||||
const rows = await db.select().from(schema.downstreamApiKeys)
|
||||
.where(eq(schema.downstreamApiKeys.userId, ctx.userId))
|
||||
.all();
|
||||
reply.send({ keys: rows.map(toDownstreamApiKeyPolicyView) });
|
||||
});
|
||||
|
||||
app.post('/api/user-api-keys', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsed = normalizeDownstreamApiKeyPayload({
|
||||
name: body.name,
|
||||
key: body.key,
|
||||
description: body.description,
|
||||
tags: body.tags,
|
||||
enabled: body.enabled,
|
||||
expiresAt: body.expiresAt,
|
||||
maxCost: body.maxCost,
|
||||
maxRequests: body.maxRequests,
|
||||
supportedModels: body.supportedModels,
|
||||
allowedRouteIds: body.allowedRouteIds,
|
||||
siteWeightMultipliers: body.siteWeightMultipliers,
|
||||
excludedSiteIds: body.excludedSiteIds,
|
||||
excludedCredentialRefs: body.excludedCredentialRefs,
|
||||
});
|
||||
if (!parsed.name) {
|
||||
return reply.code(400).send({ error: 'Name is required' });
|
||||
}
|
||||
const finalKey = parsed.key || `sk-user-${crypto.randomUUID().replace(/-/g, '')}`;
|
||||
const id = await insertAndGetById({
|
||||
table: schema.downstreamApiKeys,
|
||||
values: {
|
||||
name: parsed.name,
|
||||
key: finalKey,
|
||||
description: parsed.description,
|
||||
groupName: parsed.groupName,
|
||||
tags: toPersistenceJson(parsed.tags),
|
||||
enabled: parsed.enabled,
|
||||
expiresAt: parsed.expiresAt,
|
||||
maxCost: parsed.maxCost,
|
||||
usedCost: 0,
|
||||
maxRequests: parsed.maxRequests,
|
||||
usedRequests: 0,
|
||||
supportedModels: toPersistenceJson(parsed.supportedModels),
|
||||
allowedRouteIds: toPersistenceJson(parsed.allowedRouteIds),
|
||||
siteWeightMultipliers: toPersistenceJson(parsed.siteWeightMultipliers),
|
||||
excludedSiteIds: toPersistenceJson(parsed.excludedSiteIds),
|
||||
excludedCredentialRefs: toPersistenceJson(parsed.excludedCredentialRefs),
|
||||
userId: ctx.userId,
|
||||
},
|
||||
idColumn: schema.downstreamApiKeys.id,
|
||||
});
|
||||
const key = await getDownstreamApiKeyById(id);
|
||||
reply.code(201).send({ key });
|
||||
});
|
||||
|
||||
app.patch('/api/user-api-keys/:id', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
|
||||
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) return reply.code(400).send({ error: 'Invalid key ID' });
|
||||
|
||||
const existing = await db.select().from(schema.downstreamApiKeys)
|
||||
.where(and(
|
||||
eq(schema.downstreamApiKeys.id, id),
|
||||
eq(schema.downstreamApiKeys.userId, ctx.userId),
|
||||
))
|
||||
.get();
|
||||
if (!existing) return reply.code(404).send({ error: 'Key not found' });
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsed = normalizeDownstreamApiKeyPayload({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
tags: body.tags,
|
||||
enabled: body.enabled,
|
||||
expiresAt: body.expiresAt,
|
||||
maxCost: body.maxCost,
|
||||
maxRequests: body.maxRequests,
|
||||
supportedModels: body.supportedModels,
|
||||
allowedRouteIds: body.allowedRouteIds,
|
||||
siteWeightMultipliers: body.siteWeightMultipliers,
|
||||
excludedSiteIds: body.excludedSiteIds,
|
||||
excludedCredentialRefs: body.excludedCredentialRefs,
|
||||
});
|
||||
|
||||
const updates: Partial<Record<string, unknown>> = {};
|
||||
if (body.name !== undefined) updates.name = parsed.name;
|
||||
if (body.description !== undefined) updates.description = parsed.description;
|
||||
if (body.tags !== undefined) updates.tags = toPersistenceJson(parsed.tags);
|
||||
if (body.enabled !== undefined) updates.enabled = parsed.enabled;
|
||||
if (body.expiresAt !== undefined) updates.expiresAt = parsed.expiresAt;
|
||||
if (body.maxCost !== undefined) updates.maxCost = parsed.maxCost;
|
||||
if (body.maxRequests !== undefined) updates.maxRequests = parsed.maxRequests;
|
||||
if (body.supportedModels !== undefined) updates.supportedModels = toPersistenceJson(parsed.supportedModels);
|
||||
if (body.allowedRouteIds !== undefined) updates.allowedRouteIds = toPersistenceJson(parsed.allowedRouteIds);
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
|
||||
await db.update(schema.downstreamApiKeys).set(updates).where(eq(schema.downstreamApiKeys.id, id)).run();
|
||||
const key = await getDownstreamApiKeyById(id);
|
||||
reply.send({ key });
|
||||
});
|
||||
|
||||
app.delete('/api/user-api-keys/:id', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
|
||||
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) return reply.code(400).send({ error: 'Invalid key ID' });
|
||||
|
||||
const existing = await db.select().from(schema.downstreamApiKeys)
|
||||
.where(and(
|
||||
eq(schema.downstreamApiKeys.id, id),
|
||||
eq(schema.downstreamApiKeys.userId, ctx.userId),
|
||||
))
|
||||
.get();
|
||||
if (!existing) return reply.code(404).send({ error: 'Key not found' });
|
||||
|
||||
await db.delete(schema.downstreamApiKeys).where(eq(schema.downstreamApiKeys.id, id)).run();
|
||||
reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import {
|
||||
authenticateUser,
|
||||
createUser,
|
||||
generateJwt,
|
||||
getUserById,
|
||||
listUsers,
|
||||
updateUser,
|
||||
updateUserPassword,
|
||||
type UserView,
|
||||
} from '../../services/userService.js';
|
||||
import { userAuthMiddleware, getUserAuthContext, requireAdmin } from '../../middleware/auth.js';
|
||||
import {
|
||||
registerUserSchema,
|
||||
loginUserSchema,
|
||||
updateUserSchema,
|
||||
updatePasswordSchema,
|
||||
} from '../../contracts/userRoutePayloads.js';
|
||||
|
||||
function omitPassword(user: UserView): Omit<UserView, never> {
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function usersRoutes(app: FastifyInstance) {
|
||||
app.post('/api/users/register', async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsed = registerUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.format() });
|
||||
}
|
||||
const existing = await db.select({ id: schema.users.id })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.email, parsed.data.email.trim().toLowerCase()))
|
||||
.get();
|
||||
if (existing) {
|
||||
return reply.code(409).send({ error: 'Email already registered' });
|
||||
}
|
||||
const user = await createUser(parsed.data);
|
||||
const token = generateJwt({ userId: user.id, email: user.email, role: user.role });
|
||||
reply.code(201).send({ user: omitPassword(user), token });
|
||||
});
|
||||
|
||||
app.post('/api/users/login', async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsed = loginUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.format() });
|
||||
}
|
||||
const user = await authenticateUser(parsed.data.email, parsed.data.password);
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'Invalid email or password' });
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
return reply.code(403).send({ error: 'Account is disabled' });
|
||||
}
|
||||
const token = generateJwt({ userId: user.id, email: user.email, role: user.role });
|
||||
reply.code(200).send({ user: omitPassword(user), token });
|
||||
});
|
||||
|
||||
app.get('/api/users/me', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
const user = await getUserById(ctx.userId);
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: 'User not found' });
|
||||
}
|
||||
reply.send({ user: omitPassword(user) });
|
||||
});
|
||||
|
||||
app.patch('/api/users/me', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsed = updateUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.format() });
|
||||
}
|
||||
const updates: Partial<{ username: string }> = {};
|
||||
if (parsed.data.username !== undefined) updates.username = parsed.data.username;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateUser(ctx.userId, updates);
|
||||
}
|
||||
const user = await getUserById(ctx.userId);
|
||||
reply.send({ user: user ? omitPassword(user) : null });
|
||||
});
|
||||
|
||||
app.post('/api/users/me/password', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsed = updatePasswordSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.format() });
|
||||
}
|
||||
const currentUser = await authenticateUser(ctx.email, parsed.data.oldPassword);
|
||||
if (!currentUser) {
|
||||
return reply.code(401).send({ error: 'Current password is incorrect' });
|
||||
}
|
||||
await updateUserPassword(ctx.userId, parsed.data.newPassword);
|
||||
reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get('/api/users', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
await requireAdmin(request, reply);
|
||||
if (reply.sent) return;
|
||||
const users = await listUsers();
|
||||
reply.send({ users: users.map(omitPassword) });
|
||||
});
|
||||
|
||||
app.get('/api/users/:id', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid user ID' });
|
||||
}
|
||||
if (ctx.userId !== id && ctx.role !== 'admin') {
|
||||
return reply.code(403).send({ error: 'Forbidden' });
|
||||
}
|
||||
const user = await getUserById(id);
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: 'User not found' });
|
||||
}
|
||||
reply.send({ user: omitPassword(user) });
|
||||
});
|
||||
|
||||
app.patch('/api/users/:id', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
await requireAdmin(request, reply);
|
||||
if (reply.sent) return;
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid user ID' });
|
||||
}
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsed = updateUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.format() });
|
||||
}
|
||||
await updateUser(id, parsed.data);
|
||||
const user = await getUserById(id);
|
||||
reply.send({ user: user ? omitPassword(user) : null });
|
||||
});
|
||||
|
||||
app.delete('/api/users/:id', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
await requireAdmin(request, reply);
|
||||
if (reply.sent) return;
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid user ID' });
|
||||
}
|
||||
await updateUser(id, { status: 'disabled' });
|
||||
reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
|
||||
const METAPI_BASE = `http://127.0.0.1:${process.env.PORT || 3000}`;
|
||||
|
||||
interface AgentRequest {
|
||||
script: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Video Generation Agent
|
||||
*
|
||||
* Workflow:
|
||||
* 1. User provides script
|
||||
* 2. Agent parses script → characters, scenes, dialogue
|
||||
* 3. For characters: generate front/back/left/right concept art
|
||||
* 4. For scenes: generate background/environment prompts
|
||||
* 5. For dialogue: generate TTS audio
|
||||
* 6. Package everything for ComfyUI workflow
|
||||
*/
|
||||
|
||||
// Default system prompt for the script analysis agent
|
||||
const SCRIPT_ANALYSIS_PROMPT = `你是一个 AI 视频生成助手。分析用户提供的脚本,提取以下结构化信息,以 JSON 格式返回:
|
||||
|
||||
{
|
||||
"title": "视频标题",
|
||||
"characters": [
|
||||
{
|
||||
"name": "角色名",
|
||||
"gender": "男/女",
|
||||
"age": "年龄描述",
|
||||
"appearance": "外貌详细描述(发型、脸型、服装、体型等)",
|
||||
"personality": "性格特征"
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": 1,
|
||||
"description": "场景描述",
|
||||
"environment": "环境详细描述(时间、地点、天气、光线等)",
|
||||
"characters": ["出现的角色名"],
|
||||
"action": "场景中发生的动作",
|
||||
"dialogue": [
|
||||
{"character": "角色名", "line": "台词", "emotion": "情感语气"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"style": "整体视觉风格(动画/写实/像素等)"
|
||||
}
|
||||
|
||||
只返回 JSON,不要其他文字。`;
|
||||
|
||||
const CHARACTER_PROMPT_GEN = `根据角色描述生成 4 组稳定的图像生成提示词(正面、背面、左侧面、右侧面),用于 AI 图像生成模型。
|
||||
每组提示词应包含:人物姿态、服装、光照、背景。
|
||||
返回 JSON 格式:
|
||||
{
|
||||
"front": "正面提示词",
|
||||
"back": "背面提示词",
|
||||
"left": "左侧面提示词",
|
||||
"right": "右侧面提示词"
|
||||
}
|
||||
只返回 JSON。`;
|
||||
|
||||
const SCENE_PROMPT_GEN = `根据场景描述生成图像生成提示词,用于 AI 图像生成模型创建场景背景。
|
||||
包含:环境、光照、色调、氛围。
|
||||
返回 JSON:
|
||||
{
|
||||
"prompt": "场景生成提示词",
|
||||
"negative_prompt": "负面提示词"
|
||||
}
|
||||
只返回 JSON。`;
|
||||
|
||||
async function callLLM(prompt: string, system: string): Promise<string> {
|
||||
const resp = await fetch(`${METAPI_BASE}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer admin`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-5.5',
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.7,
|
||||
}),
|
||||
});
|
||||
const data: any = await resp.json();
|
||||
return data?.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
async function callImageGen(prompt: string): Promise<string> {
|
||||
const resp = await fetch(`${METAPI_BASE}/v1/images/generations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer admin`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'dall-e-3',
|
||||
prompt,
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
}),
|
||||
});
|
||||
const data: any = await resp.json();
|
||||
return data?.data?.[0]?.url || '';
|
||||
}
|
||||
|
||||
async function callTTS(text: string, voice = 'alloy'): Promise<Buffer | null> {
|
||||
const resp = await fetch(`${METAPI_BASE}/v1/audio/speech`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer admin`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'tts-1',
|
||||
input: text,
|
||||
voice,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const buffer = await resp.arrayBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
export async function videoAgentRoutes(app: FastifyInstance) {
|
||||
// POST /api/video-agent/analyze — analyze script and extract structure
|
||||
app.post('/api/video-agent/analyze', async (request, reply) => {
|
||||
const { script } = request.body as AgentRequest;
|
||||
if (!script) {
|
||||
return reply.code(400).send({ error: 'script is required' });
|
||||
}
|
||||
try {
|
||||
const result = await callLLM(script, SCRIPT_ANALYSIS_PROMPT);
|
||||
const parsed = JSON.parse(result.replace(/```json|```/g, '').trim());
|
||||
return { success: true, data: parsed };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: `Analysis failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/video-agent/character-prompts — generate character image prompts
|
||||
app.post('/api/video-agent/character-prompts', async (request, reply) => {
|
||||
const { character } = request.body as any;
|
||||
if (!character) {
|
||||
return reply.code(400).send({ error: 'character is required' });
|
||||
}
|
||||
try {
|
||||
const desc = `${character.name}: ${character.appearance}, ${character.gender}, ${character.age}`;
|
||||
const result = await callLLM(desc, CHARACTER_PROMPT_GEN);
|
||||
const prompts = JSON.parse(result.replace(/```json|```/g, '').trim());
|
||||
return { success: true, data: prompts };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: `Prompt generation failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/video-agent/generate-character — generate character images (front/back/left/right)
|
||||
app.post('/api/video-agent/generate-character', async (request, reply) => {
|
||||
const { prompts } = request.body as any;
|
||||
if (!prompts) {
|
||||
return reply.code(400).send({ error: 'prompts is required' });
|
||||
}
|
||||
try {
|
||||
const results: Record<string, string> = {};
|
||||
for (const [angle, prompt] of Object.entries(prompts)) {
|
||||
const url = await callImageGen(prompt as string);
|
||||
results[angle] = url;
|
||||
}
|
||||
return { success: true, data: results };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: `Image generation failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/video-agent/scene-prompt — generate scene image prompt
|
||||
app.post('/api/video-agent/scene-prompt', async (request, reply) => {
|
||||
const { scene } = request.body as any;
|
||||
if (!scene) {
|
||||
return reply.code(400).send({ error: 'scene is required' });
|
||||
}
|
||||
try {
|
||||
const desc = `${scene.environment}. ${scene.description}`;
|
||||
const result = await callLLM(desc, SCENE_PROMPT_GEN);
|
||||
const prompt = JSON.parse(result.replace(/```json|```/g, '').trim());
|
||||
return { success: true, data: prompt };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: `Scene prompt failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/video-agent/generate-scene — generate scene image
|
||||
app.post('/api/video-agent/generate-scene', async (request, reply) => {
|
||||
const { prompt } = request.body as any;
|
||||
if (!prompt) {
|
||||
return reply.code(400).send({ error: 'prompt is required' });
|
||||
}
|
||||
try {
|
||||
const url = await callImageGen(prompt);
|
||||
return { success: true, data: { url } };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: `Scene generation failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/video-agent/generate-tts — generate TTS audio for a line
|
||||
app.post('/api/video-agent/generate-tts', async (request, reply) => {
|
||||
const { text, voice } = request.body as any;
|
||||
if (!text) {
|
||||
return reply.code(400).send({ error: 'text is required' });
|
||||
}
|
||||
try {
|
||||
const audio = await callTTS(text, voice || 'alloy');
|
||||
if (!audio) {
|
||||
return reply.code(502).send({ error: 'TTS generation failed' });
|
||||
}
|
||||
return reply.type('audio/mpeg').send(audio);
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: `TTS failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/video-agent/full-workflow — run complete workflow: analyze + generate all assets
|
||||
app.post('/api/video-agent/full-workflow', async (request, reply) => {
|
||||
const { script, style } = request.body as AgentRequest;
|
||||
if (!script) {
|
||||
return reply.code(400).send({ error: 'script is required' });
|
||||
}
|
||||
try {
|
||||
// Phase 1: Analyze script
|
||||
const analysisRaw = await callLLM(script, SCRIPT_ANALYSIS_PROMPT);
|
||||
const analysis = JSON.parse(analysisRaw.replace(/```json|```/g, '').trim());
|
||||
|
||||
// Phase 2: Generate character prompts and images
|
||||
const characters: any[] = [];
|
||||
for (const char of (analysis.characters || [])) {
|
||||
const desc = `${char.name}: ${char.appearance}, ${char.gender}, ${char.age}`;
|
||||
const promptsRaw = await callLLM(desc, CHARACTER_PROMPT_GEN);
|
||||
const prompts = JSON.parse(promptsRaw.replace(/```json|```/g, '').trim());
|
||||
characters.push({ ...char, imagePrompts: prompts });
|
||||
}
|
||||
|
||||
// Phase 3: Generate scene prompts
|
||||
const scenes: any[] = [];
|
||||
for (const scene of (analysis.scenes || [])) {
|
||||
const desc = `${scene.environment}. ${scene.description}`;
|
||||
const scenePromptRaw = await callLLM(desc, SCENE_PROMPT_GEN);
|
||||
const scenePrompt = JSON.parse(scenePromptRaw.replace(/```json|```/g, '').trim());
|
||||
scenes.push({ ...scene, imagePrompt: scenePrompt });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
title: analysis.title,
|
||||
style: analysis.style || style,
|
||||
characters,
|
||||
scenes,
|
||||
},
|
||||
};
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: `Workflow failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
import { config } from '../../config.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { reportProxyAllFailed } from '../../services/alertService.js';
|
||||
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||
import { composeProxyLogMessage } from '../../services/proxyLogMessage.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
|
||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../proxy-core/downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js';
|
||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import { runWithSiteApiEndpointPool, SiteApiEndpointRequestError } from '../../services/siteApiEndpointService.js';
|
||||
import {
|
||||
buildForcedChannelUnavailableMessage,
|
||||
canRetryChannelSelection,
|
||||
getTesterForcedChannelId,
|
||||
selectProxyChannelForAttempt,
|
||||
} from '../../proxy-core/channelSelection.js';
|
||||
|
||||
export async function audioProxyRoute(app: FastifyInstance) {
|
||||
ensureMultipartBufferParser(app);
|
||||
|
||||
app.post('/v1/audio/speech', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const requestedModel = typeof body?.model === 'string' ? body.model.trim() : '';
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
return await handleAudioProxy(request, reply, '/v1/audio/speech', 'POST', requestedModel, async (selected, upstreamModel, targetUrl) => {
|
||||
const forwardBody = { ...body, model: upstreamModel };
|
||||
const startTime = Date.now();
|
||||
const response = await fetchWithObservedFirstByte(
|
||||
async (signal) => fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${selected.tokenValue}`,
|
||||
},
|
||||
body: JSON.stringify(forwardBody),
|
||||
signal,
|
||||
}, getProxyUrlFromExtraConfig(selected.account.extraConfig))),
|
||||
{ firstByteTimeoutMs: Math.max(0, Math.trunc((config.proxyFirstByteTimeoutSec || 0) * 1000)), startedAtMs: startTime },
|
||||
);
|
||||
return response;
|
||||
}, true);
|
||||
});
|
||||
|
||||
app.post('/v1/audio/transcriptions', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
await handleAudioMultipart(request, reply, '/v1/audio/transcriptions');
|
||||
});
|
||||
|
||||
app.post('/v1/audio/translations', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
await handleAudioMultipart(request, reply, '/v1/audio/translations');
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAudioMultipart(request: FastifyRequest, reply: FastifyReply, downstreamPath: string) {
|
||||
const multipartForm = await parseMultipartFormData(request);
|
||||
const jsonBody = (!multipartForm && request.body && typeof request.body === 'object')
|
||||
? request.body as Record<string, unknown>
|
||||
: null;
|
||||
const requestedModel = typeof multipartForm?.get('model') === 'string'
|
||||
? String(multipartForm.get('model')).trim()
|
||||
: (typeof jsonBody?.model === 'string' ? jsonBody.model.trim() : '');
|
||||
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
|
||||
return await handleAudioProxy(request, reply, downstreamPath, 'POST', requestedModel, async (selected, upstreamModel, targetUrl) => {
|
||||
const startTime = Date.now();
|
||||
const requestInit = multipartForm
|
||||
? withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${selected.tokenValue}`,
|
||||
},
|
||||
body: cloneFormDataWithOverrides(multipartForm, { model: upstreamModel }) as any,
|
||||
}, getProxyUrlFromExtraConfig(selected.account.extraConfig))
|
||||
: withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${selected.tokenValue}`,
|
||||
},
|
||||
body: JSON.stringify({ ...(jsonBody || {}), model: upstreamModel }),
|
||||
}, getProxyUrlFromExtraConfig(selected.account.extraConfig));
|
||||
|
||||
const response = await fetchWithObservedFirstByte(
|
||||
async (signal) => fetch(targetUrl, { ...requestInit, signal }),
|
||||
{ firstByteTimeoutMs: Math.max(0, Math.trunc((config.proxyFirstByteTimeoutSec || 0) * 1000)), startedAtMs: startTime },
|
||||
);
|
||||
return response;
|
||||
}, false);
|
||||
}
|
||||
|
||||
type AudioProxyHandler = (
|
||||
selected: any,
|
||||
upstreamModel: string,
|
||||
targetUrl: string,
|
||||
) => Promise<Response>;
|
||||
|
||||
async function handleAudioProxy(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
downstreamPath: string,
|
||||
_method: string,
|
||||
requestedModel: string,
|
||||
dispatch: AudioProxyHandler,
|
||||
isBinary: boolean,
|
||||
) {
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const forcedChannelId = getTesterForcedChannelId({
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
clientIp: request.ip,
|
||||
});
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
downstreamPath,
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
body: request.body as Record<string, unknown> | undefined,
|
||||
});
|
||||
const firstByteTimeoutMs = Math.max(0, Math.trunc((config.proxyFirstByteTimeoutSec || 0) * 1000));
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= getProxyMaxChannelRetries()) {
|
||||
const selected = await selectProxyChannelForAttempt({
|
||||
requestedModel,
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
forcedChannelId,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
const noChannelMessage = buildForcedChannelUnavailableMessage(forcedChannelId);
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: forcedChannelId ? noChannelMessage : 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: noChannelMessage, type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
const upstreamModel = selected.actualModel || requestedModel;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const { upstream, firstByteLatencyMs } = await runWithSiteApiEndpointPool(selected.site, async (target) => {
|
||||
const targetUrl = buildUpstreamUrl(target.baseUrl, downstreamPath);
|
||||
const response = await dispatch(selected, upstreamModel, targetUrl);
|
||||
const observedFirstByteLatencyMs = getObservedResponseMeta(response)?.firstByteLatencyMs ?? null;
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => 'unknown error');
|
||||
throw new SiteApiEndpointRequestError(text, {
|
||||
status: response.status,
|
||||
rawErrText: text || null,
|
||||
firstByteLatencyMs: observedFirstByteLatencyMs,
|
||||
});
|
||||
}
|
||||
return { upstream: response, firstByteLatencyMs: observedFirstByteLatencyMs };
|
||||
});
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
let estimatedCost = 0;
|
||||
await recordTokenRouterEventBestEffort('estimate proxy cost', async () => {
|
||||
estimatedCost = await estimateProxyCost({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
modelName: upstreamModel,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
});
|
||||
});
|
||||
await recordTokenRouterEventBestEffort('record channel success', () => (
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, upstreamModel)
|
||||
));
|
||||
await recordTokenRouterEventBestEffort('record downstream cost usage', () => (
|
||||
recordDownstreamCostUsage(request, estimatedCost)
|
||||
));
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
upstream.status,
|
||||
latency,
|
||||
null,
|
||||
retryCount,
|
||||
downstreamApiKeyId,
|
||||
estimatedCost,
|
||||
downstreamPath,
|
||||
clientContext,
|
||||
false,
|
||||
firstByteLatencyMs,
|
||||
);
|
||||
|
||||
if (isBinary) {
|
||||
const contentType = upstream.headers.get('content-type') || 'audio/mpeg';
|
||||
const buffer = Buffer.from(await upstream.arrayBuffer());
|
||||
return reply.code(upstream.status).type(contentType).send(buffer);
|
||||
}
|
||||
|
||||
const text = await upstream.text();
|
||||
let data: unknown;
|
||||
try { data = JSON.parse(text); } catch { data = text; }
|
||||
return reply.code(upstream.status).type('application/json').send(data);
|
||||
} catch (err: any) {
|
||||
const status = err instanceof SiteApiEndpointRequestError ? (err.status || 0) : 0;
|
||||
const errorText = err?.message || 'network failure';
|
||||
const firstByteLatencyMs = err instanceof SiteApiEndpointRequestError ? err.firstByteLatencyMs : null;
|
||||
await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, {
|
||||
status,
|
||||
errorText,
|
||||
modelName: upstreamModel,
|
||||
}));
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
status,
|
||||
Date.now() - startTime,
|
||||
errorText,
|
||||
retryCount,
|
||||
downstreamApiKeyId,
|
||||
0,
|
||||
downstreamPath,
|
||||
clientContext,
|
||||
false,
|
||||
firstByteLatencyMs,
|
||||
);
|
||||
if ((status > 0 ? shouldRetryProxyRequest(status, errorText) : true) && canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: errorText || 'network failure',
|
||||
});
|
||||
return reply.code(status || 502).send({
|
||||
error: {
|
||||
message: status > 0 ? errorText : `Upstream error: ${errorText}`,
|
||||
type: 'upstream_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
httpStatus: number,
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
downstreamApiKeyId: number | null,
|
||||
estimatedCost: number,
|
||||
downstreamPath: string,
|
||||
clientContext: DownstreamClientContext | null,
|
||||
isStream: boolean,
|
||||
firstByteLatencyMs: number | null,
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||
? clientContext.clientKind
|
||||
: null,
|
||||
sessionId: clientContext?.sessionId || null,
|
||||
traceHint: clientContext?.traceHint || null,
|
||||
downstreamPath,
|
||||
errorMessage,
|
||||
});
|
||||
await insertProxyLog({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
downstreamApiKeyId,
|
||||
modelRequested,
|
||||
modelActual: selected.actualModel || modelRequested,
|
||||
status,
|
||||
httpStatus,
|
||||
isStream,
|
||||
firstByteLatencyMs,
|
||||
latencyMs,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
estimatedCost,
|
||||
clientFamily: clientContext?.clientKind || null,
|
||||
clientAppId: clientContext?.clientAppId || null,
|
||||
clientAppName: clientContext?.clientAppName || null,
|
||||
clientConfidence: clientContext?.clientConfidence || null,
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount,
|
||||
createdAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[proxy/audio] failed to write proxy log', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordTokenRouterEventBestEffort(
|
||||
label: string,
|
||||
operation: () => Promise<unknown> | unknown,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
console.warn(`[proxy/audio] failed to ${label}`, error);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { searchProxyRoute } from './search.js';
|
||||
import { geminiProxyRoute } from './gemini.js';
|
||||
import { videosProxyRoute } from './videos.js';
|
||||
import { filesProxyRoute } from './files.js';
|
||||
import { audioProxyRoute } from './audio.js';
|
||||
|
||||
export async function proxyRoutes(app: FastifyInstance) {
|
||||
// Auth middleware for all /v1 routes
|
||||
@@ -27,5 +28,6 @@ export async function proxyRoutes(app: FastifyInstance) {
|
||||
await app.register(filesProxyRoute);
|
||||
await app.register(imagesProxyRoute);
|
||||
await app.register(videosProxyRoute);
|
||||
await app.register(audioProxyRoute);
|
||||
await app.register(geminiProxyRoute);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { config } from '../config.js';
|
||||
import { isUserActive } from './userService.js';
|
||||
import {
|
||||
EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||
type DownstreamExcludedCredentialRef,
|
||||
@@ -29,6 +30,7 @@ export type DownstreamApiKeyPolicyView = {
|
||||
siteWeightMultipliers: Record<number, number>;
|
||||
excludedSiteIds: number[];
|
||||
excludedCredentialRefs: DownstreamExcludedCredentialRef[];
|
||||
userId: number | null;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
@@ -367,6 +369,7 @@ export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): Downstre
|
||||
siteWeightMultipliers,
|
||||
excludedSiteIds,
|
||||
excludedCredentialRefs,
|
||||
userId: row.userId ?? null,
|
||||
lastUsedAt: row.lastUsedAt || null,
|
||||
createdAt: row.createdAt || null,
|
||||
updatedAt: row.updatedAt || null,
|
||||
@@ -467,6 +470,15 @@ export async function authorizeDownstreamToken(token: string): Promise<Downstrea
|
||||
};
|
||||
}
|
||||
|
||||
if (managed.userId !== null && !await isUserActive(managed.userId)) {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: 403,
|
||||
error: 'User account is disabled',
|
||||
reason: 'disabled',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
source: 'managed',
|
||||
|
||||
@@ -78,7 +78,7 @@ function buildTelegramText(
|
||||
timeFootnote: string,
|
||||
): string {
|
||||
const maxTextLength = 3900;
|
||||
const raw = `[metapi][${level.toUpperCase()}] ${title}\n\n${message}\n\nLevel: ${level}\n${timeFootnote}`;
|
||||
const raw = `[BoosAPI][${level.toUpperCase()}] ${title}\n\n${message}\n\nLevel: ${level}\n${timeFootnote}`;
|
||||
if (raw.length <= maxTextLength) return raw;
|
||||
return `${raw.slice(0, maxTextLength)}\n\n...(truncated)`;
|
||||
}
|
||||
@@ -99,7 +99,7 @@ function buildWeComText(
|
||||
timeFootnote: string,
|
||||
): string {
|
||||
const maxLength = 1900;
|
||||
const raw = `[metapi][${level.toUpperCase()}] ${title}\n\n${message}\n\n${timeFootnote}`;
|
||||
const raw = `[BoosAPI][${level.toUpperCase()}] ${title}\n\n${message}\n\n${timeFootnote}`;
|
||||
if (raw.length <= maxLength) return raw;
|
||||
return `${raw.slice(0, maxLength)}\n...(truncated)`;
|
||||
}
|
||||
@@ -123,7 +123,7 @@ function buildFeishuText(
|
||||
timeFootnote: string,
|
||||
): string {
|
||||
const maxLength = 3900;
|
||||
const raw = `[metapi][${level.toUpperCase()}] ${title}\n\n${message}\n\n${timeFootnote}`;
|
||||
const raw = `[BoosAPI][${level.toUpperCase()}] ${title}\n\n${message}\n\n${timeFootnote}`;
|
||||
if (raw.length <= maxLength) return raw;
|
||||
return `${raw.slice(0, maxLength)}\n...(truncated)`;
|
||||
}
|
||||
@@ -315,7 +315,7 @@ export async function sendNotification(
|
||||
run: () => transporter.sendMail({
|
||||
from: config.smtpFrom,
|
||||
to: config.smtpTo,
|
||||
subject: `[metapi][${level.toUpperCase()}] ${title}`,
|
||||
subject: `[BoosAPI][${level.toUpperCase()}] ${title}`,
|
||||
text: `${resolvedMessage}\n\nLevel: ${level}\n${timeFootnote}`,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -121,7 +121,7 @@ async function handleCallbackRequest(
|
||||
});
|
||||
respondHtml(response, 200, 'OAuth authorization succeeded. You can close this window.');
|
||||
} catch {
|
||||
respondHtml(response, 500, 'OAuth authorization failed. Return to metapi and review the server logs.');
|
||||
respondHtml(response, 500, 'OAuth authorization failed. Return to BoosAPI and review the server logs.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -366,7 +366,7 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
}
|
||||
|
||||
private buildDefaultTokenPayload(options?: CreateApiTokenOptions): Record<string, unknown> {
|
||||
const normalizedName = (options?.name || '').trim() || 'metapi';
|
||||
const normalizedName = (options?.name || '').trim() || 'BoosAPI';
|
||||
const unlimitedQuota = options?.unlimitedQuota ?? true;
|
||||
const remainQuota = Number.isFinite(options?.remainQuota)
|
||||
? Math.max(0, Math.trunc(options?.remainQuota as number))
|
||||
|
||||
@@ -20,7 +20,7 @@ export class OneApiAdapter extends BasePlatformAdapter {
|
||||
}
|
||||
|
||||
private buildDefaultTokenPayload(options?: CreateApiTokenOptions): CreateApiTokenPayload {
|
||||
const normalizedName = (options?.name || '').trim() || 'metapi';
|
||||
const normalizedName = (options?.name || '').trim() || 'BoosAPI';
|
||||
const unlimitedQuota = options?.unlimitedQuota ?? true;
|
||||
const remainQuota = Number.isFinite(options?.remainQuota)
|
||||
? Math.max(0, Math.trunc(options?.remainQuota as number))
|
||||
|
||||
@@ -819,7 +819,7 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
||||
): Promise<boolean> {
|
||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||
const payload: Record<string, unknown> = {
|
||||
name: (options?.name || '').trim() || 'metapi',
|
||||
name: (options?.name || '').trim() || 'BoosAPI',
|
||||
};
|
||||
|
||||
const groupId = Number.parseInt((options?.group || '').trim(), 10);
|
||||
|
||||
@@ -41,7 +41,7 @@ export function buildStartupSummaryLines(input: StartupSummaryInput): string[] {
|
||||
const endpoints = buildStartupEndpoints(input);
|
||||
|
||||
return [
|
||||
`metapi running on ${input.host}:${input.port}`,
|
||||
`BoosAPI running on ${input.host}:${input.port}`,
|
||||
`Dashboard: ${endpoints.adminDashboardUrl}`,
|
||||
`Admin API: ${endpoints.adminApiExample}`,
|
||||
`Proxy API: ${endpoints.proxyApiExample}`,
|
||||
|
||||
@@ -25,7 +25,7 @@ export function getDefaultUpdateCenterConfig(): UpdateCenterConfig {
|
||||
namespace: 'default',
|
||||
releaseName: '',
|
||||
chartRef: '',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageRepository: '1467078763/boosapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
|
||||
@@ -45,8 +45,8 @@ export type DockerHubTagCandidates = {
|
||||
};
|
||||
|
||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
||||
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/cita-777/metapi/releases';
|
||||
const DOCKER_HUB_TAGS_URL = 'https://hub.docker.com/v2/repositories/1467078763/metapi/tags?page_size=100';
|
||||
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/cita-777/boosapi/releases';
|
||||
const DOCKER_HUB_TAGS_URL = 'https://hub.docker.com/v2/repositories/1467078763/boosapi/tags?page_size=100';
|
||||
const UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS = 5_000;
|
||||
const PREFERRED_DOCKER_HUB_TAG_ALIASES = ['latest', 'main'] as const;
|
||||
const MAX_RECENT_NON_STABLE_DOCKER_HUB_TAGS = 5;
|
||||
@@ -284,7 +284,7 @@ export async function fetchLatestStableGitHubRelease(): Promise<UpdateCenterVers
|
||||
const releases = await fetchJsonWithTimeout(GITHUB_RELEASES_URL, {
|
||||
headers: {
|
||||
accept: 'application/vnd.github+json',
|
||||
'user-agent': 'metapi-update-center/1.0',
|
||||
'user-agent': 'boosapi-update-center/1.0',
|
||||
},
|
||||
}, 'GitHub releases lookup') as GitHubReleaseRecord[];
|
||||
return selectLatestStableGitHubRelease(Array.isArray(releases) ? releases : []);
|
||||
@@ -298,7 +298,7 @@ export async function fetchDockerHubTagCandidates(): Promise<DockerHubTagCandida
|
||||
const payload = await fetchJsonWithTimeout(DOCKER_HUB_TAGS_URL, {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'user-agent': 'metapi-update-center/1.0',
|
||||
'user-agent': 'boosapi-update-center/1.0',
|
||||
},
|
||||
}, 'Docker Hub tag lookup') as { results?: DockerHubTagRecord[] };
|
||||
return selectDockerHubTagCandidates(Array.isArray(payload?.results) ? payload.results : []);
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createHmac, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { config } from '../config.js';
|
||||
import { insertAndGetById } from '../db/insertHelpers.js';
|
||||
|
||||
const scryptAsync = promisify(scrypt);
|
||||
|
||||
const SALT_LEN = 32;
|
||||
const KEY_LEN = 64;
|
||||
const JWT_SECRET = config.accountCredentialSecret;
|
||||
|
||||
function generateSalt(): string {
|
||||
return randomBytes(SALT_LEN).toString('hex');
|
||||
}
|
||||
|
||||
async function hashPassword(password: string, salt: string): Promise<string> {
|
||||
const derived = await scryptAsync(password, salt, KEY_LEN) as Buffer;
|
||||
return `${salt}:${derived.toString('hex')}`;
|
||||
}
|
||||
|
||||
async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const [salt, hash] = stored.split(':');
|
||||
if (!salt || !hash) return false;
|
||||
const derived = await scryptAsync(password, salt, KEY_LEN) as Buffer;
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(hash, 'hex'), derived);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type UserRole = 'admin' | 'user';
|
||||
export type UserStatus = 'active' | 'disabled';
|
||||
|
||||
export type UserRow = typeof schema.users.$inferSelect;
|
||||
|
||||
export type UserView = {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type JwtPayload = {
|
||||
userId: number;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
function base64UrlEncode(input: string): string {
|
||||
return Buffer.from(input)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function base64UrlDecode(input: string): string {
|
||||
const padding = '='.repeat((4 - (input.length % 4)) % 4);
|
||||
const base64 = input.replace(/-/g, '+').replace(/_/g, '/') + padding;
|
||||
return Buffer.from(base64, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
export function generateJwt(payload: Omit<JwtPayload, 'iat' | 'exp'> & { expiresInMs?: number }): string {
|
||||
const now = Math.trunc(Date.now() / 1000);
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const body: JwtPayload = {
|
||||
userId: payload.userId,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
iat: now,
|
||||
exp: now + Math.trunc((payload.expiresInMs ?? 12 * 60 * 60 * 1000) / 1000),
|
||||
};
|
||||
const segments = [
|
||||
base64UrlEncode(JSON.stringify(header)),
|
||||
base64UrlEncode(JSON.stringify(body)),
|
||||
];
|
||||
const signature = createHmac('sha256', JWT_SECRET)
|
||||
.update(segments.join('.'))
|
||||
.digest()
|
||||
.toString('base64url');
|
||||
return `${segments[0]}.${segments[1]}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyJwt(token: string): JwtPayload | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const [headerB64, payloadB64, signature] = parts;
|
||||
if (!headerB64 || !payloadB64 || !signature) return null;
|
||||
|
||||
const expected = createHmac('sha256', JWT_SECRET)
|
||||
.update(`${headerB64}.${payloadB64}`)
|
||||
.digest()
|
||||
.toString('base64url');
|
||||
|
||||
if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(base64UrlDecode(payloadB64)) as JwtPayload;
|
||||
const now = Math.trunc(Date.now() / 1000);
|
||||
if (typeof payload.exp === 'number' && payload.exp <= now) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toUserView(row: UserRow): UserView {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
role: (row.role as UserRole) || 'user',
|
||||
status: (row.status as UserStatus) || 'active',
|
||||
createdAt: row.createdAt || null,
|
||||
updatedAt: row.updatedAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createUser(input: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role?: UserRole;
|
||||
}): Promise<UserView> {
|
||||
const salt = generateSalt();
|
||||
const passwordHash = await hashPassword(input.password, salt);
|
||||
const row = await insertAndGetById<UserRow>({
|
||||
table: schema.users,
|
||||
values: {
|
||||
username: input.username.trim(),
|
||||
email: input.email.trim().toLowerCase(),
|
||||
passwordHash,
|
||||
role: input.role ?? 'user',
|
||||
status: 'active',
|
||||
},
|
||||
idColumn: schema.users.id,
|
||||
insertErrorMessage: 'Failed to create user',
|
||||
});
|
||||
return toUserView(row);
|
||||
}
|
||||
|
||||
export async function authenticateUser(email: string, password: string): Promise<UserView | null> {
|
||||
const row = await db.select().from(schema.users)
|
||||
.where(eq(schema.users.email, email.trim().toLowerCase()))
|
||||
.get();
|
||||
if (!row) return null;
|
||||
const valid = await verifyPassword(password, row.passwordHash);
|
||||
if (!valid) return null;
|
||||
return toUserView(row);
|
||||
}
|
||||
|
||||
export async function getUserById(id: number): Promise<UserView | null> {
|
||||
const row = await db.select().from(schema.users).where(eq(schema.users.id, id)).get();
|
||||
if (!row) return null;
|
||||
return toUserView(row);
|
||||
}
|
||||
|
||||
export async function listUsers(): Promise<UserView[]> {
|
||||
const rows = await db.select().from(schema.users).all();
|
||||
return rows.map(toUserView).sort((a, b) => b.id - a.id);
|
||||
}
|
||||
|
||||
export async function updateUser(id: number, input: Partial<Pick<UserRow, 'username' | 'role' | 'status'>>): Promise<void> {
|
||||
const updates: Partial<Record<string, unknown>> = {};
|
||||
if (input.username !== undefined) updates.username = input.username.trim();
|
||||
if (input.role !== undefined) updates.role = input.role;
|
||||
if (input.status !== undefined) updates.status = input.status;
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
await db.update(schema.users).set(updates).where(eq(schema.users.id, id)).run();
|
||||
}
|
||||
|
||||
export async function updateUserPassword(id: number, password: string): Promise<void> {
|
||||
const salt = generateSalt();
|
||||
const passwordHash = await hashPassword(password, salt);
|
||||
await db.update(schema.users).set({
|
||||
passwordHash,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.users.id, id)).run();
|
||||
}
|
||||
|
||||
export async function isUserActive(id: number): Promise<boolean> {
|
||||
const row = await db.select({ status: schema.users.status })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, id))
|
||||
.get();
|
||||
return row?.status === 'active';
|
||||
}
|
||||
Reference in New Issue
Block a user