feat: support R2 image attachments in agent conversations

This commit is contained in:
2026-10-19 13:34:06 +09:00
parent ed70bad7a2
commit f58a20f262
15 changed files with 930 additions and 16 deletions
@@ -2,6 +2,23 @@ import { ConfigService } from '@nestjs/config';
import { R2PublicFileService } from './r2-public-file.service';
describe('R2 direct upload URL', () => {
it('bounds image object reads and closes streams on oversized responses', async () => {
const service = new R2PublicFileService(new ConfigService({ R2_BUCKET: 'test', R2_PUBLIC_BASE_URL: 'https://files.test', R2_ACCESS_KEY_ID: 'test', R2_SECRET_ACCESS_KEY: 'test' }));
const body = { async *[Symbol.asyncIterator]() { yield Buffer.from('12'); yield Buffer.from('34'); }, destroy: jest.fn() };
const send = jest.fn().mockResolvedValue({ ContentLength: 4, Body: body });
(service as unknown as { client: unknown }).client = { send };
expect(await service.readPublicFile('studio-files/public/image.png', 4)).toEqual(Buffer.from('1234'));
expect(body.destroy).toHaveBeenCalledTimes(1);
send.mockResolvedValueOnce({ ContentLength: 100, Body: body });
await expect(service.readPublicFile('studio-files/public/image.png', 4)).rejects.toThrow(/크기/);
expect(body.destroy).toHaveBeenCalledTimes(2);
send.mockResolvedValueOnce({ ContentLength: 2, Body: body });
await expect(service.readPublicFile('studio-files/public/image.png', 3)).rejects.toThrow(/크기/);
expect(body.destroy).toHaveBeenCalledTimes(3);
await expect(service.readPublicFile('private/image.png', 4)).rejects.toThrow();
expect(send).toHaveBeenCalledTimes(3);
});
it('signs a PUT without a checksum for an empty body', async () => {
const service = new R2PublicFileService(new ConfigService({
R2_BUCKET: 'test', R2_ACCOUNT_ID: 'test',
+24 -1
View File
@@ -1,4 +1,4 @@
import { DeleteObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@@ -79,6 +79,29 @@ export class R2PublicFileService {
return this.publicUrl(input.key);
}
createLmsAgentImageKey(input: { courseId: string; userId: string; imageId: string }) {
return [this.publicPrefix, 'lms', 'agent-attachments', input.courseId, input.userId, `${input.imageId}.png`].join('/');
}
async readPublicFile(key: string, maxBytes: number): Promise<Buffer> {
if (!this.isConfigured() || !this.isPublicKey(key)) throw new Error('이미지 저장소가 설정되지 않았습니다.');
const result = await this.getClient().send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
if (!result.Body) throw new Error('첨부 이미지를 찾을 수 없습니다.');
const chunks: Buffer[] = [];
let bytes = 0;
try {
if ((result.ContentLength ?? maxBytes + 1) > maxBytes) throw new Error('첨부 이미지 크기가 올바르지 않습니다.');
for await (const chunk of result.Body as AsyncIterable<Uint8Array>) {
bytes += chunk.length;
if (bytes > maxBytes) throw new Error('첨부 이미지 크기가 올바르지 않습니다.');
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks);
} finally {
if ('destroy' in result.Body && typeof result.Body.destroy === 'function') result.Body.destroy();
}
}
async deletePublicFile(key: string) {
if (!this.isConfigured() || !this.isPublicKey(key)) return;