import type { IncomingMessage, ServerResponse } from 'node:http'; import type { HostApiContext } from '../context'; import { sendJson } from '../route-utils'; const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024; const SUPPORTED_IMAGE_MIMES = new Set([ 'image/png', 'image/jpeg', 'image/webp', 'image/gif', ]); function contentType(req: IncomingMessage): string { const value = req.headers['content-type']; return (Array.isArray(value) ? value[0] : value)?.split(';', 1)[0]?.trim().toLowerCase() ?? ''; } async function readBoundedBody(req: IncomingMessage): Promise { const declared = Number(req.headers['content-length']); if (Number.isFinite(declared) && (declared <= 0 || declared > MAX_ATTACHMENT_BYTES)) { throw Object.assign(new Error('Attachment size is invalid'), { status: 413 }); } const chunks: Buffer[] = []; let byteLength = 0; for await (const chunk of req) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); byteLength += buffer.byteLength; if (byteLength > MAX_ATTACHMENT_BYTES) { throw Object.assign(new Error('Attachment size is invalid'), { status: 413 }); } chunks.push(buffer); } if (byteLength === 0) throw Object.assign(new Error('Attachment is empty'), { status: 400 }); return Buffer.concat(chunks); } function sendAttachmentError(res: ServerResponse, error: unknown): void { const status = typeof (error as { status?: unknown })?.status === 'number' ? (error as { status: number }).status : (error as NodeJS.ErrnoException)?.code === 'ENOENT' ? 404 : 500; const message = status === 413 ? '图片不能超过 16 MB。' : status === 404 ? '图片附件不存在。' : status === 400 ? '图片附件无效。' : '图片附件暂时无法读取。'; sendJson(res, status, { success: false, code: status === 413 ? 'CODING_ATTACHMENT_TOO_LARGE' : status === 404 ? 'CODING_ATTACHMENT_NOT_FOUND' : status === 400 ? 'CODING_ATTACHMENT_INVALID' : 'CODING_ATTACHMENT_STORAGE_FAILED', error: message, }); } export async function handleCodingAttachmentRoutes( req: IncomingMessage, res: ServerResponse, url: URL, ctx: HostApiContext, ): Promise { const upload = url.pathname === '/api/coding/attachments' && req.method === 'POST'; const contentMatch = url.pathname.match(/^\/api\/coding\/attachments\/([^/]+)\/content$/); const content = Boolean(contentMatch && req.method === 'GET'); if (!upload && !content) return false; const attachments = ctx.codingProducts?.attachments; if (!attachments) { sendJson(res, 503, { success: false, code: 'CODING_CORE_UNAVAILABLE', error: '本地编程服务暂时不可用。', }); return true; } try { if (upload) { const mime = contentType(req); if (!SUPPORTED_IMAGE_MIMES.has(mime)) { throw Object.assign(new Error('Attachment MIME is invalid'), { status: 400 }); } const attachment = await attachments.put(await readBoundedBody(req), mime); sendJson(res, 201, attachment); return true; } const attachmentId = decodeURIComponent(contentMatch?.[1] ?? ''); const record = await attachments.read(attachmentId); if (!SUPPORTED_IMAGE_MIMES.has(record.mime)) { throw Object.assign(new Error('Attachment MIME is invalid'), { status: 400 }); } res.statusCode = 200; res.setHeader('Content-Type', record.mime); res.setHeader('Content-Length', String(record.byteLength)); res.setHeader('Cache-Control', 'private, no-store'); res.end(record.data); return true; } catch (error) { sendAttachmentError(res, error); return true; } }