import { randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { access, mkdir, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import type { FastifyInstance, FastifyReply } from 'fastify'; import { z } from 'zod'; import { env } from '../config/env.js'; import { hasErrorLogViewAccessToken } from '../services/error-log-service.js'; import { getActiveChatService, getChatRuntimeController } from '../services/chat-service.js'; import { createChatConversation, deleteUnansweredChatConversationRequest, deleteChatConversation, ensureChatConversationTables, getChatConversation, listChatConversationDetailPage, listChatConversations, markChatConversationResponsesRead, updateChatConversationContext, } from '../services/chat-room-service.js'; import { chatRuntimeService } from '../services/chat-runtime-service.js'; const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 20 * 1024 * 1024; const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 10 * 1024 * 1024; const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/'; const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources'; function resolveStaticContentType(filePath: string) { const extension = path.extname(filePath).toLowerCase(); switch (extension) { case '.ts': case '.tsx': case '.js': case '.jsx': case '.mjs': case '.cjs': case '.json': case '.css': case '.html': case '.md': case '.txt': case '.diff': return 'text/plain; charset=utf-8'; case '.svg': return 'image/svg+xml'; case '.png': return 'image/png'; case '.jpg': case '.jpeg': return 'image/jpeg'; case '.gif': return 'image/gif'; case '.webp': return 'image/webp'; case '.ico': return 'image/x-icon'; case '.pdf': return 'application/pdf'; default: return 'application/octet-stream'; } } function buildChatResourcePublicUrl(relativePath: string) { const normalizedRelativePath = relativePath.replace(/^public\//, '').replace(/^\/+/, ''); return `${CHAT_API_RESOURCE_ROUTE_PREFIX}/${normalizedRelativePath .split('/') .filter(Boolean) .map((segment) => encodeURIComponent(segment)) .join('/')}`; } function normalizeChatResourceWildcard(wildcard: string) { const cleaned = wildcard.trim().replace(/^\/+/, '').replace(/^public\//, ''); if (!cleaned) { return ''; } if (cleaned.startsWith('.codex_chat/')) { return cleaned; } return path.posix.join('.codex_chat', cleaned); } async function serveChatPublicResource( repoPath: string, wildcard: string, reply: FastifyReply, ) { const requestedRelativePath = normalizeChatResourceWildcard(wildcard); if (!requestedRelativePath) { return reply.code(404).send({ message: '채팅 리소스를 찾을 수 없습니다.', }); } const publicRoot = path.join(repoPath, 'public'); const absolutePath = path.resolve(publicRoot, requestedRelativePath); if (!absolutePath.startsWith(`${publicRoot}${path.sep}`)) { return reply.code(403).send({ message: '허용되지 않은 경로입니다.', }); } try { await access(absolutePath); const fileStat = await stat(absolutePath); if (!fileStat.isFile()) { return reply.code(404).send({ message: '채팅 리소스를 찾을 수 없습니다.', }); } } catch { return reply.code(404).send({ message: '채팅 리소스를 찾을 수 없습니다.', }); } reply.header('Cache-Control', 'no-store'); reply.type(resolveStaticContentType(absolutePath)); return reply.send(createReadStream(absolutePath)); } function sanitizeChatAttachmentFileName(fileName: string) { const normalized = fileName.trim().replace(/[\\/:"*?<>|]+/g, '-').replace(/\s+/g, ' '); const compact = normalized || 'attachment'; return compact.length > 120 ? compact.slice(-120) : compact; } function resolveChatAttachmentRepoPath() { return path.resolve(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH); } function getClientIdHeader(request: { headers: Record }) { const raw = request.headers['x-client-id']; return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim(); } function canViewAllConversations(request: { headers: Record }) { return hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined); } export async function registerChatRoutes(app: FastifyInstance) { app.get(`${CHAT_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => { const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim(); return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply); }); app.get(`${CHAT_API_RESOURCE_ROUTE_PREFIX}/*`, async (request, reply) => { const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim(); return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply); }); app.get('/api/chat/setup', async () => { await ensureChatConversationTables(); return { ok: true, tables: ['chat_conversations', 'chat_conversation_messages', 'chat_conversation_requests'], }; }); app.get('/api/chat/conversations', async (request) => { const query = z.object({ limit: z.coerce.number().int().min(1).max(200).optional(), }).parse(request.query ?? {}); const viewerClientId = getClientIdHeader(request); const clientId = canViewAllConversations(request) ? null : viewerClientId; const items = await listChatConversations(clientId, query.limit ?? 50, viewerClientId || null); return { ok: true, items, }; }); app.get('/api/chat/runtime', async () => { return { ok: true, item: chatRuntimeService.getSnapshot(), }; }); app.post('/api/chat/attachments', { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => { const payload = z.object({ sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/), fileName: z.string().trim().min(1).max(255), mimeType: z.string().trim().max(200).optional(), contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2), }).parse(request.body ?? {}); const buffer = Buffer.from(payload.contentBase64, 'base64'); if (buffer.byteLength === 0) { return reply.code(400).send({ message: '업로드할 파일 내용을 찾지 못했습니다.', }); } if (buffer.byteLength > CHAT_ATTACHMENT_FILE_SIZE_LIMIT) { return reply.code(413).send({ message: '첨부 파일은 10MB 이하만 업로드할 수 있습니다.', }); } const safeFileName = sanitizeChatAttachmentFileName(payload.fileName); const fileToken = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`; const relativePath = path.posix.join( 'public', '.codex_chat', payload.sessionId, 'resource', 'uploads', `${fileToken}-${safeFileName}`, ); const absolutePath = path.join(resolveChatAttachmentRepoPath(), ...relativePath.split('/')); await mkdir(path.dirname(absolutePath), { recursive: true }); await writeFile(absolutePath, buffer); return { ok: true, item: { id: randomUUID(), name: payload.fileName, path: relativePath, publicUrl: buildChatResourcePublicUrl(relativePath), size: buffer.byteLength, mimeType: payload.mimeType?.trim() || 'application/octet-stream', }, }; }); app.get('/api/chat/runtime/jobs/:requestId', async (request, reply) => { const params = z.object({ requestId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const controller = getChatRuntimeController(); if (!controller) { return reply.code(503).send({ message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.', }); } return { ok: true, item: controller.getJobDetail(params.requestId), }; }); app.post('/api/chat/runtime/jobs/:requestId/cancel', async (request, reply) => { const params = z.object({ requestId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const controller = getChatRuntimeController(); if (!controller) { return reply.code(503).send({ message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.', }); } const cancelled = await controller.cancelJob(params.requestId); if (!cancelled) { return reply.code(404).send({ message: '취소할 실행 중 요청을 찾지 못했습니다.', }); } return { ok: true, cancelled: true, }; }); app.post('/api/chat/runtime/jobs/:requestId/remove', async (request, reply) => { const params = z.object({ requestId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const controller = getChatRuntimeController(); if (!controller) { return reply.code(503).send({ message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.', }); } const removed = await controller.removeQueuedJob(params.requestId); if (!removed) { return reply.code(404).send({ message: '제거할 대기 요청을 찾지 못했습니다.', }); } return { ok: true, removed: true, }; }); app.post('/api/chat/conversations', async (request) => { const payload = z.object({ sessionId: z.string().trim().min(1).max(120), title: z.string().trim().max(200).optional(), chatTypeId: z.string().trim().max(120).nullable().optional(), contextLabel: z.string().trim().max(200).optional(), contextDescription: z.string().trim().max(2000).optional(), notifyOffline: z.boolean().optional(), }).parse(request.body ?? {}); const clientId = getClientIdHeader(request); const item = await createChatConversation({ sessionId: payload.sessionId, clientId: clientId || null, title: payload.title ?? '새 대화', chatTypeId: payload.chatTypeId ?? null, contextLabel: payload.contextLabel ?? null, contextDescription: payload.contextDescription ?? null, notifyOffline: payload.notifyOffline ?? true, }); return { ok: true, item, }; }); app.get('/api/chat/conversations/:sessionId', async (request, reply) => { const params = z.object({ sessionId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const query = z.object({ limit: z.coerce.number().int().min(1).max(500).optional(), beforeMessageId: z.coerce.number().int().positive().optional(), }).parse(request.query ?? {}); const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request); const item = await getChatConversation(params.sessionId, clientId || null); if (!item) { return reply.code(404).send({ message: '채팅방을 찾을 수 없습니다.', }); } const messageLimit = query.limit ?? 6; const detailPage = await listChatConversationDetailPage(params.sessionId, { limit: messageLimit, beforeMessageId: query.beforeMessageId ?? null, }); return { ok: true, item, messages: detailPage.messages, requests: detailPage.requests, activityLogs: detailPage.activityLogs, oldestLoadedMessageId: detailPage.oldestLoadedMessageId, hasOlderMessages: detailPage.hasOlderMessages, }; }); app.post('/api/chat/conversations/:sessionId/read', async (request, reply) => { const params = z.object({ sessionId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const clientId = getClientIdHeader(request); if (!clientId) { return reply.code(400).send({ message: '읽음 처리를 위한 clientId가 필요합니다.', }); } const result = await markChatConversationResponsesRead(params.sessionId, clientId); if (!result) { return reply.code(404).send({ message: '읽음 처리할 채팅방을 찾지 못했습니다.', }); } return { ok: true, ...result, }; }); app.delete('/api/chat/conversations/:sessionId/requests/:requestId', async (request, reply) => { const params = z.object({ sessionId: z.string().trim().min(1).max(120), requestId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const result = await deleteUnansweredChatConversationRequest(params.sessionId, params.requestId); if (!result.deleted) { if (result.reason === 'not_found') { return reply.code(404).send({ message: '삭제할 요청을 찾지 못했습니다.', }); } if (result.reason === 'answered') { return reply.code(409).send({ message: '이미 답변이 연결된 요청은 삭제할 수 없습니다.', }); } return reply.code(409).send({ message: '현재 처리 중인 요청은 삭제할 수 없습니다.', }); } return { ok: true, deleted: true, sessionId: params.sessionId, requestId: params.requestId, }; }); app.patch('/api/chat/conversations/:sessionId', async (request, reply) => { const params = z.object({ sessionId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const payload = z.object({ title: z.string().trim().min(1).max(200).optional(), chatTypeId: z.string().trim().max(120).optional().nullable(), contextLabel: z.string().trim().max(200).optional().nullable(), contextDescription: z.string().trim().max(2000).optional().nullable(), notifyOffline: z.boolean().optional(), }).parse(request.body ?? {}); const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request); const current = await getChatConversation(params.sessionId, clientId || null); if (!current) { return reply.code(404).send({ message: '수정할 채팅방을 찾을 수 없습니다.', }); } const item = await updateChatConversationContext(params.sessionId, { title: payload.title ?? current.title, clientId: current.clientId, chatTypeId: payload.chatTypeId ?? current.chatTypeId, contextLabel: payload.contextLabel ?? current.contextLabel, contextDescription: payload.contextDescription ?? current.contextDescription, notifyOffline: payload.notifyOffline ?? current.notifyOffline, }); return { ok: true, item, }; }); app.delete('/api/chat/conversations/:sessionId', async (request, reply) => { const params = z.object({ sessionId: z.string().trim().min(1).max(120), }).parse(request.params ?? {}); const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request); const current = await getChatConversation(params.sessionId, clientId || null); if (!current) { return reply.code(404).send({ message: '삭제할 채팅방을 찾을 수 없습니다.', }); } await getActiveChatService()?.forgetSession(params.sessionId); const deleted = await deleteChatConversation(params.sessionId); return { ok: true, deleted, sessionId: params.sessionId, }; }); }