From 9afabedca872684b2df676a1dea6563004df4433 Mon Sep 17 00:00:00 2001 From: digua Date: Mon, 11 May 2026 20:28:26 +0800 Subject: [PATCH] feat(server): extract standalone HTTP API from Electron Create packages/server/src/http/ with platform-agnostic HTTP API: - errors.ts: error codes and response helpers - auth.ts: Bearer token auth using @openchatlab/config - server.ts: Fastify factory with auth hook and error handler - routes/system.ts: status and schema endpoints - routes/sessions.ts: 7 session endpoints using DatabaseManager + core - index.ts: server lifecycle (startHttpServer/stopHttpServer) Add token field to @openchatlab/config apiConfigSchema. Add fastify dependency to packages/server. --- packages/config/src/schema.ts | 1 + packages/server/package.json | 7 +- packages/server/src/http/auth.ts | 44 ++++ packages/server/src/http/errors.ts | 94 ++++++++ packages/server/src/http/index.ts | 113 ++++++++++ packages/server/src/http/routes/sessions.ts | 232 ++++++++++++++++++++ packages/server/src/http/routes/system.ts | 92 ++++++++ packages/server/src/http/server.ts | 38 ++++ packages/server/src/index.ts | 1 + pnpm-lock.yaml | 4 + 10 files changed, 623 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/http/auth.ts create mode 100644 packages/server/src/http/errors.ts create mode 100644 packages/server/src/http/index.ts create mode 100644 packages/server/src/http/routes/sessions.ts create mode 100644 packages/server/src/http/routes/system.ts create mode 100644 packages/server/src/http/server.ts diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index c917049d..e87c0251 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -20,6 +20,7 @@ export const dataConfigSchema = z.object({ export const apiConfigSchema = z.object({ port: z.number().int().min(1).max(65535).default(3210), host: z.string().default('127.0.0.1'), + token: z.string().default(''), }) export const localeConfigSchema = z.object({ diff --git a/packages/server/package.json b/packages/server/package.json index 7dde8272..06f2201b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -14,13 +14,14 @@ "rebuild-sqlite": "npm rebuild better-sqlite3" }, "dependencies": { - "@openchatlab/core": "workspace:*", + "@modelcontextprotocol/sdk": "^1.12.1", "@openchatlab/config": "workspace:*", + "@openchatlab/core": "workspace:*", "@openchatlab/node-runtime": "workspace:*", "@openchatlab/shared-types": "workspace:*", - "@modelcontextprotocol/sdk": "^1.12.1", "@openchatlab/tools": "workspace:*", - "commander": "^13.1.0" + "commander": "^13.1.0", + "fastify": "^5.8.4" }, "devDependencies": { "tsx": "^4.21.0" diff --git a/packages/server/src/http/auth.ts b/packages/server/src/http/auth.ts new file mode 100644 index 00000000..e2c1a0dc --- /dev/null +++ b/packages/server/src/http/auth.ts @@ -0,0 +1,44 @@ +/** + * ChatLab HTTP API — Bearer Token authentication hook + * + * 从 electron/main/api/auth.ts 迁移,使用 @openchatlab/config 读取 token。 + */ + +import type { FastifyRequest, FastifyReply } from 'fastify' +import { timingSafeEqual } from 'crypto' +import { unauthorized, errorResponse } from './errors' + +let cachedToken: string | null = null + +/** + * 设置 auth hook 使用的 token(由 server 启动时注入) + */ +export function setAuthToken(token: string): void { + cachedToken = token +} + +function safeTokenCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a) + const bufB = Buffer.from(b) + if (bufA.length !== bufB.length) return false + return timingSafeEqual(bufA, bufB) +} + +export async function authHook(request: FastifyRequest, reply: FastifyReply): Promise { + if (!cachedToken) return + + const authHeader = request.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + const err = unauthorized() + reply.code(err.statusCode).send(errorResponse(err)) + return + } + + const token = authHeader.slice(7) + + if (!safeTokenCompare(token, cachedToken)) { + const err = unauthorized() + reply.code(err.statusCode).send(errorResponse(err)) + return + } +} diff --git a/packages/server/src/http/errors.ts b/packages/server/src/http/errors.ts new file mode 100644 index 00000000..23e9dbb4 --- /dev/null +++ b/packages/server/src/http/errors.ts @@ -0,0 +1,94 @@ +/** + * ChatLab HTTP API — Error codes and factory functions + * + * 从 electron/main/api/errors.ts 迁移,完全平台无关。 + */ + +export enum ApiErrorCode { + UNAUTHORIZED = 'UNAUTHORIZED', + SESSION_NOT_FOUND = 'SESSION_NOT_FOUND', + INVALID_FORMAT = 'INVALID_FORMAT', + INVALID_PAYLOAD = 'INVALID_PAYLOAD', + SQL_READONLY_VIOLATION = 'SQL_READONLY_VIOLATION', + SQL_EXECUTION_ERROR = 'SQL_EXECUTION_ERROR', + EXPORT_TOO_LARGE = 'EXPORT_TOO_LARGE', + BODY_TOO_LARGE = 'BODY_TOO_LARGE', + SERVER_ERROR = 'SERVER_ERROR', +} + +const HTTP_STATUS: Record = { + [ApiErrorCode.UNAUTHORIZED]: 401, + [ApiErrorCode.SESSION_NOT_FOUND]: 404, + [ApiErrorCode.INVALID_FORMAT]: 400, + [ApiErrorCode.INVALID_PAYLOAD]: 400, + [ApiErrorCode.SQL_READONLY_VIOLATION]: 400, + [ApiErrorCode.SQL_EXECUTION_ERROR]: 400, + [ApiErrorCode.EXPORT_TOO_LARGE]: 400, + [ApiErrorCode.BODY_TOO_LARGE]: 413, + [ApiErrorCode.SERVER_ERROR]: 500, +} + +export class ApiError extends Error { + code: ApiErrorCode + statusCode: number + + constructor(code: ApiErrorCode, message: string) { + super(message) + this.name = 'ApiError' + this.code = code + this.statusCode = HTTP_STATUS[code] + } +} + +export function unauthorized(message = 'Invalid or missing token'): ApiError { + return new ApiError(ApiErrorCode.UNAUTHORIZED, message) +} + +export function sessionNotFound(id: string): ApiError { + return new ApiError(ApiErrorCode.SESSION_NOT_FOUND, `Session not found: ${id}`) +} + +export function invalidPayload(message: string): ApiError { + return new ApiError(ApiErrorCode.INVALID_PAYLOAD, message) +} + +export function sqlReadonlyViolation(): ApiError { + return new ApiError(ApiErrorCode.SQL_READONLY_VIOLATION, 'Only SELECT queries are allowed') +} + +export function sqlExecutionError(message: string): ApiError { + return new ApiError(ApiErrorCode.SQL_EXECUTION_ERROR, message) +} + +export function exportTooLarge(count: number, limit: number): ApiError { + return new ApiError( + ApiErrorCode.EXPORT_TOO_LARGE, + `Message count ${count} exceeds export limit ${limit}. Use paginated /messages API instead.` + ) +} + +export function serverError(message = 'Internal server error'): ApiError { + return new ApiError(ApiErrorCode.SERVER_ERROR, message) +} + +export function successResponse(data: T, meta?: Record) { + return { + success: true as const, + data, + meta: { + timestamp: Math.floor(Date.now() / 1000), + version: '0.0.2', + ...meta, + }, + } +} + +export function errorResponse(error: ApiError) { + return { + success: false as const, + error: { + code: error.code, + message: error.message, + }, + } +} diff --git a/packages/server/src/http/index.ts b/packages/server/src/http/index.ts new file mode 100644 index 00000000..d6f668af --- /dev/null +++ b/packages/server/src/http/index.ts @@ -0,0 +1,113 @@ +/** + * ChatLab HTTP API — Server lifecycle manager + * + * 独立于 Electron 的 HTTP API 服务入口。 + * 使用 DatabaseManager + @openchatlab/core 直接访问数据。 + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' +import type { FastifyInstance } from 'fastify' +import { loadConfig, getConfigDir } from '@openchatlab/config' +import type { ChatLabConfig } from '@openchatlab/config' +import { NodePathProvider, DatabaseManager } from '@openchatlab/node-runtime' +import { createServer } from './server' +import { setAuthToken } from './auth' +import { registerSystemRoutes } from './routes/system' +import { registerSessionRoutes } from './routes/sessions' + +let server: FastifyInstance | null = null +let dbManager: DatabaseManager | null = null + +export interface HttpServerOptions { + port?: number + host?: string + token?: string +} + +function resolveNativeBinding(): string | undefined { + if (process.versions.electron) return undefined + const nativePath = path.resolve(__dirname, '../../native/better_sqlite3.node') + if (fs.existsSync(nativePath)) return nativePath + return undefined +} + +function ensureToken(config: ChatLabConfig): string { + if (config.api.token) return config.api.token + + const token = `clb_${crypto.randomBytes(32).toString('hex')}` + const configDir = getConfigDir() + const configPath = path.join(configDir, 'config.toml') + try { + let content = '' + if (fs.existsSync(configPath)) { + content = fs.readFileSync(configPath, 'utf-8') + } + if (!content.includes('[api]')) { + content += `\n[api]\ntoken = "${token}"\n` + } else { + content = content.replace(/\[api\]/, `[api]\ntoken = "${token}"`) + } + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync(configPath, content, 'utf-8') + } catch { + // 写入失败时仍使用生成的 token 运行本次会话 + } + return token +} + +/** + * 启动独立 HTTP API 服务 + */ +export async function startHttpServer(options?: HttpServerOptions): Promise<{ + port: number + host: string + token: string +}> { + if (server) { + throw new Error('HTTP server is already running') + } + + const config = loadConfig() + const port = options?.port ?? config.api.port + const host = options?.host ?? config.api.host + const token = options?.token ?? ensureToken(config) + + const dataDir = config.data.dir || undefined + const pathProvider = new NodePathProvider(dataDir) + pathProvider.ensureAllDirs() + const nativeBinding = resolveNativeBinding() + dbManager = new DatabaseManager(pathProvider, { nativeBinding }) + + setAuthToken(token) + + server = createServer() + registerSystemRoutes(server, dbManager) + registerSessionRoutes(server, dbManager) + + await server.listen({ port, host }) + + return { port, host, token } +} + +/** + * 停止 HTTP API 服务 + */ +export async function stopHttpServer(): Promise { + if (!server) return + + try { + await server.close() + } finally { + if (dbManager) { + dbManager.closeAll() + dbManager = null + } + server = null + } +} + +export { createServer } from './server' +export { registerSystemRoutes } from './routes/system' +export { registerSessionRoutes } from './routes/sessions' diff --git a/packages/server/src/http/routes/sessions.ts b/packages/server/src/http/routes/sessions.ts new file mode 100644 index 00000000..e0fecabd --- /dev/null +++ b/packages/server/src/http/routes/sessions.ts @@ -0,0 +1,232 @@ +/** + * ChatLab HTTP API — Session routes + * + * 从 electron/main/api/routes/sessions.ts 迁移。 + * 使用 DatabaseManager + @openchatlab/core 替代 workerManager。 + */ + +import type { FastifyInstance } from 'fastify' +import type { DatabaseManager } from '@openchatlab/node-runtime' +import { + getSessionMeta, + getSessionOverview, + queryMessages, + getMembers, + getMembersDetailed, + getMemberActivity, + getMessageTypeStats, + executeReadonlySql, +} from '@openchatlab/core' +import { + successResponse, + errorResponse, + sessionNotFound, + exportTooLarge, + sqlExecutionError, + ApiError, +} from '../errors' + +const EXPORT_MESSAGE_LIMIT = 100_000 + +function ensureDb(dbManager: DatabaseManager, sessionId: string) { + const db = dbManager.open(sessionId) + if (!db) throw sessionNotFound(sessionId) + return db +} + +export function registerSessionRoutes(server: FastifyInstance, dbManager: DatabaseManager): void { + // GET /api/v1/sessions — List all sessions + server.get('/api/v1/sessions', async () => { + const sessionIds = dbManager.listSessionIds() + const sessions = sessionIds + .map((id) => { + const db = dbManager.open(id) + if (!db) return null + const meta = getSessionMeta(db) + if (!meta) return null + const overview = getSessionOverview(db) + return { + id, + name: meta.name, + platform: meta.platform, + type: meta.type, + groupId: meta.groupId || undefined, + messageCount: overview.totalMessages, + memberCount: overview.totalMembers, + firstTimestamp: overview.firstMessageTs, + lastTimestamp: overview.lastMessageTs, + } + }) + .filter(Boolean) + return successResponse(sessions) + }) + + // GET /api/v1/sessions/:id — Single session detail + server.get<{ Params: { id: string } }>('/api/v1/sessions/:id', async (request) => { + const db = ensureDb(dbManager, request.params.id) + const meta = getSessionMeta(db) + if (!meta) throw sessionNotFound(request.params.id) + const overview = getSessionOverview(db) + return successResponse({ + id: request.params.id, + name: meta.name, + platform: meta.platform, + type: meta.type, + groupId: meta.groupId || undefined, + messageCount: overview.totalMessages, + memberCount: overview.totalMembers, + firstTimestamp: overview.firstMessageTs, + lastTimestamp: overview.lastMessageTs, + }) + }) + + // GET /api/v1/sessions/:id/messages — Query messages (paginated) + server.get<{ + Params: { id: string } + Querystring: { + page?: string + limit?: string + startTime?: string + endTime?: string + keyword?: string + senderId?: string + } + }>('/api/v1/sessions/:id/messages', async (request) => { + const { id } = request.params + const db = ensureDb(dbManager, id) + + const page = Math.max(1, parseInt(request.query.page || '1', 10) || 1) + const limit = Math.min(1000, Math.max(1, parseInt(request.query.limit || '100', 10) || 100)) + const offset = (page - 1) * limit + + const { startTime, endTime, keyword, senderId } = request.query + + const result = queryMessages(db, { + keyword: keyword || undefined, + startTs: startTime ? parseInt(startTime, 10) : undefined, + endTs: endTime ? parseInt(endTime, 10) : undefined, + senderId: senderId ? parseInt(senderId, 10) : undefined, + limit, + offset, + }) + + return successResponse({ + messages: result.messages, + total: result.total, + page, + limit, + totalPages: result.totalPages, + }) + }) + + // GET /api/v1/sessions/:id/members — Member list + server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/members', async (request) => { + const db = ensureDb(dbManager, request.params.id) + const members = getMembers(db) + return successResponse(members) + }) + + // GET /api/v1/sessions/:id/stats/overview — Overview statistics + server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/stats/overview', async (request) => { + const { id } = request.params + const db = ensureDb(dbManager, id) + + const overview = getSessionOverview(db) + const memberActivity = getMemberActivity(db) + const typeDistribution = getMessageTypeStats(db) + + const typeMap: Record = {} + for (const item of typeDistribution) { + typeMap[String(item.type)] = item.count + } + + const topMembers = memberActivity.slice(0, 10).map((m) => ({ + platformId: m.platformId, + name: m.name, + messageCount: m.messageCount, + percentage: m.percentage, + })) + + return successResponse({ + messageCount: overview.totalMessages, + memberCount: overview.totalMembers, + timeRange: { + start: overview.firstMessageTs ?? 0, + end: overview.lastMessageTs ?? 0, + }, + messageTypeDistribution: typeMap, + topMembers, + }) + }) + + // POST /api/v1/sessions/:id/sql — Execute SQL (read-only) + server.post<{ Params: { id: string }; Body: { sql: string } }>('/api/v1/sessions/:id/sql', async (request, reply) => { + const { id } = request.params + const db = ensureDb(dbManager, id) + + const { sql } = request.body || {} + if (!sql || typeof sql !== 'string') { + const err = sqlExecutionError('Missing sql parameter') + return reply.code(err.statusCode).send(errorResponse(err)) + } + + try { + const result = executeReadonlySql(db, sql) + return successResponse(result) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'SQL execution error' + if (message.includes('SELECT') || message.includes('只读') || message.includes('readonly')) { + const apiErr = new ApiError('SQL_READONLY_VIOLATION' as ApiError['code'], message) + apiErr.statusCode = 400 + return reply.code(400).send(errorResponse(apiErr)) + } + const apiErr = sqlExecutionError(message) + return reply.code(apiErr.statusCode).send(errorResponse(apiErr)) + } + }) + + // GET /api/v1/sessions/:id/export — Export ChatLab Format JSON + server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/export', async (request, reply) => { + const { id } = request.params + const db = ensureDb(dbManager, id) + const meta = getSessionMeta(db) + if (!meta) throw sessionNotFound(id) + const overview = getSessionOverview(db) + + if (overview.totalMessages > EXPORT_MESSAGE_LIMIT) { + const err = exportTooLarge(overview.totalMessages, EXPORT_MESSAGE_LIMIT) + return reply.code(err.statusCode).send(errorResponse(err)) + } + + const members = getMembersDetailed(db) + const allMessages = queryMessages(db, { limit: EXPORT_MESSAGE_LIMIT, offset: 0 }) + + const chatLabFormat = { + chatlab: { + version: '0.0.2', + exportedAt: Math.floor(Date.now() / 1000), + generator: 'ChatLab API', + }, + meta: { + name: meta.name, + platform: meta.platform, + type: meta.type, + groupId: meta.groupId || undefined, + }, + members: members.map((m) => ({ + platformId: m.platformId, + accountName: m.accountName || m.platformId, + groupNickname: m.groupNickname || undefined, + })), + messages: allMessages.messages.map((msg) => ({ + sender: msg.senderPlatformId, + accountName: msg.senderName || undefined, + timestamp: msg.timestamp, + type: msg.type, + content: msg.content || null, + })), + } + + return successResponse(chatLabFormat) + }) +} diff --git a/packages/server/src/http/routes/system.ts b/packages/server/src/http/routes/system.ts new file mode 100644 index 00000000..d00345fd --- /dev/null +++ b/packages/server/src/http/routes/system.ts @@ -0,0 +1,92 @@ +/** + * ChatLab HTTP API — System routes + * + * GET /api/v1/status Service status + * GET /api/v1/schema ChatLab Format JSON Schema + */ + +import type { FastifyInstance } from 'fastify' +import type { DatabaseManager } from '@openchatlab/node-runtime' +import { successResponse } from '../errors' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const pkg = require('../../../package.json') as { version: string } + +export function registerSystemRoutes(server: FastifyInstance, dbManager: DatabaseManager): void { + server.get('/api/v1/status', async () => { + let sessionCount = 0 + try { + sessionCount = dbManager.listSessionIds().length + } catch { + // ignore + } + + return successResponse({ + name: 'ChatLab API', + version: pkg.version, + uptime: Math.floor(process.uptime()), + sessionCount, + }) + }) + + server.get('/api/v1/schema', async () => { + return successResponse({ + format: 'ChatLab Format', + version: '0.0.2', + spec: { + chatlab: { + type: 'object', + required: ['version'], + properties: { + version: { type: 'string' }, + exportedAt: { type: 'number' }, + generator: { type: 'string' }, + }, + }, + meta: { + type: 'object', + required: ['name', 'platform', 'type'], + properties: { + name: { type: 'string' }, + platform: { + type: 'string', + enum: ['qq', 'wechat', 'telegram', 'discord', 'line', 'whatsapp', 'instagram', 'unknown'], + }, + type: { type: 'string', enum: ['group', 'private'] }, + groupId: { type: 'string' }, + }, + }, + members: { + type: 'array', + items: { + type: 'object', + required: ['platformId', 'accountName'], + properties: { + platformId: { type: 'string' }, + accountName: { type: 'string' }, + groupNickname: { type: 'string' }, + avatar: { type: 'string' }, + }, + }, + }, + messages: { + type: 'array', + items: { + type: 'object', + required: ['sender', 'timestamp', 'type'], + properties: { + platformMessageId: { type: 'string' }, + sender: { type: 'string' }, + accountName: { type: 'string' }, + groupNickname: { type: 'string' }, + timestamp: { type: 'number' }, + type: { type: 'number' }, + content: { type: ['string', 'null'] }, + replyToMessageId: { type: 'string' }, + }, + }, + }, + }, + }) + }) +} diff --git a/packages/server/src/http/server.ts b/packages/server/src/http/server.ts new file mode 100644 index 00000000..554b17fb --- /dev/null +++ b/packages/server/src/http/server.ts @@ -0,0 +1,38 @@ +/** + * ChatLab HTTP API — Fastify server factory + * + * 从 electron/main/api/server.ts 迁移,完全平台无关。 + */ + +import Fastify, { type FastifyInstance, type FastifyError } from 'fastify' +import { authHook } from './auth' +import { ApiError, ApiErrorCode, errorResponse, serverError } from './errors' + +const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB + +export function createServer(): FastifyInstance { + const server = Fastify({ + logger: false, + bodyLimit: JSON_BODY_LIMIT, + }) + + server.addHook('onRequest', authHook) + + server.setErrorHandler((error: FastifyError, _request, reply) => { + if (error instanceof ApiError) { + reply.code(error.statusCode).send(errorResponse(error)) + return + } + + if (error.statusCode === 413) { + const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit') + reply.code(413).send(errorResponse(bodyErr)) + return + } + + const err = serverError(error.message) + reply.code(err.statusCode).send(errorResponse(err)) + }) + + return server +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c68d83ee..b1ebd6bc 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -6,3 +6,4 @@ */ export { run } from './cli' +export { startHttpServer, stopHttpServer } from './http' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e31f57d1..0c5650f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ importers: commander: specifier: ^13.1.0 version: 13.1.0 + fastify: + specifier: ^5.8.4 + version: 5.8.4 devDependencies: tsx: specifier: ^4.21.0 @@ -4373,6 +4376,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prelude-ls@1.2.1: