feat: Сервис S3 и тесты к нему
This commit is contained in:
110
apps/backend/src/services/s3.service.ts
Normal file
110
apps/backend/src/services/s3.service.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { S3Client } from 'bun';
|
||||||
|
import { env } from '@/config/env';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
class S3Service {
|
||||||
|
private client: S3Client;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = new S3Client({
|
||||||
|
endpoint: env.S3_ENDPOINT,
|
||||||
|
bucket: env.S3_BUCKET,
|
||||||
|
accessKeyId: env.S3_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(
|
||||||
|
file: File | Buffer,
|
||||||
|
userId: string,
|
||||||
|
mimeType: string
|
||||||
|
): Promise<{ key: string; url: string; size: number }> {
|
||||||
|
const ext = this.getExtensionFromMimeType(mimeType);
|
||||||
|
const key = `${userId}/${nanoid()}.${ext}`;
|
||||||
|
|
||||||
|
let size: number;
|
||||||
|
|
||||||
|
if (file instanceof File) {
|
||||||
|
size = file.size;
|
||||||
|
} else {
|
||||||
|
size = file.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3File = this.client.file(key);
|
||||||
|
|
||||||
|
await s3File.write(file, { type: mimeType });
|
||||||
|
|
||||||
|
const url = this.getPublicUrl(key);
|
||||||
|
|
||||||
|
return { key, url, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(key: string): Promise<void> {
|
||||||
|
const s3File = this.client.file(key);
|
||||||
|
await s3File.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSignedUrl(key: string, expiresIn: number = 3600): string {
|
||||||
|
const s3File = this.client.file(key);
|
||||||
|
|
||||||
|
return s3File.presign({
|
||||||
|
expiresIn,
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFromUrl(url: string): Promise<{ buffer: Buffer; mimeType: string; size: number }> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download file: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
mimeType: contentType,
|
||||||
|
size: buffer.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPublicUrl(key: string): string {
|
||||||
|
const endpoint = env.S3_ENDPOINT.replace(/\/$/, '');
|
||||||
|
return `${endpoint}/${env.S3_BUCKET}/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExtensionFromMimeType(mimeType: string): string {
|
||||||
|
const mimeToExt: Record<string, string> = {
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/jpg': 'jpg',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
'image/avif': 'avif',
|
||||||
|
'video/mp4': 'mp4',
|
||||||
|
'video/webm': 'webm',
|
||||||
|
'video/quicktime': 'mov',
|
||||||
|
'video/x-msvideo': 'avi',
|
||||||
|
};
|
||||||
|
|
||||||
|
return mimeToExt[mimeType] || 'bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileType(mimeType: string): 'image' | 'video' | 'gif' | 'unknown' {
|
||||||
|
if (mimeType === 'image/gif') return 'gif';
|
||||||
|
if (mimeType.startsWith('image/')) return 'image';
|
||||||
|
if (mimeType.startsWith('video/')) return 'video';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(key: string): Promise<Buffer> {
|
||||||
|
const s3File = this.client.file(key);
|
||||||
|
const arrayBuffer = await s3File.arrayBuffer();
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const s3Service = new S3Service();
|
||||||
37
apps/backend/src/tests/unit/s3.service.test.ts
Normal file
37
apps/backend/src/tests/unit/s3.service.test.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { s3Service } from '@/services/s3.service';
|
||||||
|
|
||||||
|
describe('S3Service', () => {
|
||||||
|
describe('getFileType', () => {
|
||||||
|
test('should identify image types', () => {
|
||||||
|
expect(s3Service.getFileType('image/jpeg')).toBe('image');
|
||||||
|
expect(s3Service.getFileType('image/png')).toBe('image');
|
||||||
|
expect(s3Service.getFileType('image/webp')).toBe('image');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should identify GIF separately', () => {
|
||||||
|
expect(s3Service.getFileType('image/gif')).toBe('gif');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should identify video types', () => {
|
||||||
|
expect(s3Service.getFileType('video/mp4')).toBe('video');
|
||||||
|
expect(s3Service.getFileType('video/webm')).toBe('video');
|
||||||
|
expect(s3Service.getFileType('video/quicktime')).toBe('video');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return unknown for unsupported types', () => {
|
||||||
|
expect(s3Service.getFileType('text/plain')).toBe('unknown');
|
||||||
|
expect(s3Service.getFileType('application/pdf')).toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('downloadFromUrl', () => {
|
||||||
|
test('should download file from valid URL', async () => {
|
||||||
|
const mockUrl = 'https://mrqiz.ru';
|
||||||
|
|
||||||
|
expect(mockUrl).toMatch(/^https?:\/\//);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user