import { BadGatewayException, BadRequestException, Injectable } from '@nestjs/common'; import { createHash, randomUUID } from 'node:crypto'; import sharp from 'sharp'; import { R2PublicFileService } from '../storage/r2-public-file.service'; import { AiCredentialService } from './ai-credential.service'; export const AGENT_IMAGE_MAX_BYTES = 5 * 1024 * 1024; const STORED_MAX_BYTES = 10 * 1024 * 1024; export type AgentImageScope = { studentId: string; courseId: string; week: number }; export type AgentImageReference = { token: string; name: string; mimeType: 'image/png'; size: number; url: string }; type ImageClaim = AgentImageScope & { type: 'agent-image'; id: string; name: string; key: string; size: number; sha256: string }; function normalizeImageFileName(fileName: string): string { if (/^[\u0000-\u00ff]*$/.test(fileName)) { try { fileName = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(fileName, 'latin1')); } catch { /* Preserve genuine Latin-1 names that are not misdecoded UTF-8. */ } } return fileName.replace(/[\\/\u0000-\u001f]/g, '_').slice(0, 255) || 'image.png'; } export function supportsAgentImages(provider: string, model: string): boolean { return provider === 'QWEN' && ['qwen3.7-plus', 'qwen3.7-flash', 'qwen3.8-max', 'qwen3.8-flash', 'qwen-vl-plus', 'qwen-vl-max'] .some(id => model === id || model.startsWith(`${id}-`)); } @Injectable() export class AgentImagesService { constructor(private readonly storage: R2PublicFileService, private readonly credentials: AiCredentialService) {} async upload(scope: AgentImageScope, file: { originalname: string; buffer: Buffer } | undefined): Promise { if (!file?.buffer.length || file.buffer.length > AGENT_IMAGE_MAX_BYTES) throw new BadRequestException('이미지는 장당 5MB 이하여야 합니다.'); if (!this.storage.isConfigured()) throw new BadRequestException('이미지 저장소가 설정되지 않았습니다.'); let body: Buffer; try { const image = sharp(file.buffer, { limitInputPixels: 16_000_000, animated: false }); const info = await image.metadata(); if (!['png', 'jpeg', 'webp'].includes(info.format ?? '') || (info.pages ?? 1) > 1) throw new Error('format'); // Decode and re-encode losslessly to remove metadata and reject malformed image bytes. body = await image.rotate().png().toBuffer(); if (body.length > STORED_MAX_BYTES) throw new Error('size'); } catch { throw new BadRequestException('PNG·JPEG·WebP 정지 이미지만 첨부할 수 있습니다. 1600만 픽셀 이하의 이미지로 다시 선택해주세요.'); } const id = randomUUID(); const key = this.storage.createLmsAgentImageKey({ courseId: scope.courseId, userId: scope.studentId, imageId: id }); const name = normalizeImageFileName(file.originalname); const claim: ImageClaim = { ...scope, type: 'agent-image', id, name, key, size: body.length, sha256: createHash('sha256').update(body).digest('hex') }; const token = this.credentials.encrypt(JSON.stringify(claim)); const url = await this.storage.uploadPublicFile({ key, body, contentType: 'image/png' }).catch(() => { throw new BadGatewayException('첨부 이미지 업로드에 실패했습니다. 잠시 후 다시 전송해주세요.'); }); if (!url) throw new BadRequestException('첨부 이미지를 저장하지 못했습니다.'); return { token, name, mimeType: 'image/png', size: body.length, url }; } references(value: unknown, scope?: AgentImageScope): AgentImageReference[] { if (value === undefined || value === null) return []; if (!Array.isArray(value) || value.length > 3) throw new BadRequestException('이미지는 최대 3장까지 첨부할 수 있습니다.'); const seen = new Set(); return value.map(item => { if (!item || typeof item !== 'object' || typeof item.token !== 'string' || item.token.length > 4000) throw new BadRequestException('첨부 이미지 정보가 올바르지 않습니다.'); let claim: ImageClaim; try { claim = JSON.parse(this.credentials.decrypt(item.token)) as ImageClaim; } catch { throw new BadRequestException('첨부 이미지 인증 정보가 올바르지 않습니다.'); } if (claim.type !== 'agent-image' || typeof claim.id !== 'string' || typeof claim.name !== 'string' || claim.name.length > 255 || !Number.isInteger(claim.size) || claim.size < 1 || claim.size > STORED_MAX_BYTES || !/^[a-f0-9]{64}$/.test(claim.sha256) || typeof claim.studentId !== 'string' || typeof claim.courseId !== 'string' || !Number.isInteger(claim.week) || claim.key !== this.storage.createLmsAgentImageKey({ courseId: claim.courseId, userId: claim.studentId, imageId: claim.id }) || (scope && (scope.studentId !== claim.studentId || scope.courseId !== claim.courseId || scope.week !== claim.week)) || seen.has(claim.id)) { throw new BadRequestException('이 대화에서 사용할 수 없는 첨부 이미지입니다.'); } seen.add(claim.id); const url = this.storage.publicUrl(claim.key); if (!url) throw new BadRequestException('첨부 이미지 주소를 찾을 수 없습니다.'); return { token: item.token, name: normalizeImageFileName(claim.name), mimeType: 'image/png', size: claim.size, url }; }); } async load(references: AgentImageReference[]): Promise> { return Promise.all(references.map(async reference => { const claim = JSON.parse(this.credentials.decrypt(reference.token)) as ImageClaim; const body = await this.storage.readPublicFile(claim.key, STORED_MAX_BYTES).catch(() => { throw new BadGatewayException('첨부 이미지를 불러오지 못했습니다. 다시 첨부해주세요.'); }); if (body.length !== claim.size || createHash('sha256').update(body).digest('hex') !== claim.sha256) throw new BadRequestException('저장된 첨부 이미지가 변경되었습니다. 다시 첨부해주세요.'); return { mimeType: 'image/png', data: body.toString('base64') }; })); } }