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