feat: support R2 image attachments in agent conversations
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user