85 lines
6.0 KiB
TypeScript
85 lines
6.0 KiB
TypeScript
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<AgentImageReference> {
|
|
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<string>();
|
|
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<Array<{ mimeType: string; data: string }>> {
|
|
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') };
|
|
}));
|
|
}
|
|
}
|