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 ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/; const SUPPORTED_IMAGE_MIMES = new Set([ 'image/png', 'image/jpeg', 'image/webp', 'image/gif', ]); function matchesImageSignature(data: Uint8Array, mime: string): boolean { if (mime === 'image/png') { return data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47 && data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a; } if (mime === 'image/jpeg') { return data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff; } if (mime === 'image/gif') { if (data.length < 6) return false; const header = Buffer.from(data.subarray(0, 6)).toString('ascii'); return header === 'GIF87a' || header === 'GIF89a'; } if (mime === 'image/webp') { return data.length >= 12 && Buffer.from(data.subarray(0, 4)).toString('ascii') === 'RIFF' && Buffer.from(data.subarray(8, 12)).toString('ascii') === 'WEBP'; } return false; } 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 > 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, operation: 'upload' | 'content', ): void { const explicitStatus = typeof (error as { status?: unknown })?.status === 'number' ? (error as { status: number }).status : null; const status = explicitStatus ?? (operation === 'content' || (error as NodeJS.ErrnoException)?.code === 'ENOENT' ? 404 : 500); const message = status === 413 ? '图片不能超过 16 MB。' : status === 404 ? '图片附件不存在。' : status === 400 ? '图片附件无效。' : operation === 'upload' ? '本地数据写入失败,请检查存储后重试。' : '图片附件暂时无法读取。'; sendJson(res, status, { success: false, code: status === 400 || status === 413 ? 'CODING_ATTACHMENT_INVALID' : status === 404 ? 'CODING_ATTACHMENT_NOT_FOUND' : 'CODING_STORAGE_WRITE_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 body = await readBoundedBody(req); if (!matchesImageSignature(body, mime)) { throw Object.assign(new Error('Attachment body is not the declared image type'), { status: 400 }); } const attachment = await attachments.put(body, mime); sendJson(res, 201, attachment); return true; } let attachmentId: string; try { attachmentId = decodeURIComponent(contentMatch?.[1] ?? ''); } catch { throw Object.assign(new Error('Attachment id is invalid'), { status: 400 }); } if (!ATTACHMENT_ID_PATTERN.test(attachmentId)) { throw Object.assign(new Error('Attachment id is invalid'), { status: 400 }); } 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, upload ? 'upload' : 'content'); return true; } }