From f3c75c3b888dd1ee685c3b7d4fd7e511fd567058 Mon Sep 17 00:00:00 2001 From: Mark Zheleznyakov Date: Wed, 19 Nov 2025 14:10:33 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=A1=D0=B5=D1=80=D0=B2=D0=B8=D1=81=20?= =?UTF-8?q?S3=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=BA=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=BC=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/services/s3.service.ts | 110 ++++++++++++++++++ .../backend/src/tests/unit/s3.service.test.ts | 37 ++++++ 2 files changed, 147 insertions(+) create mode 100644 apps/backend/src/services/s3.service.ts create mode 100644 apps/backend/src/tests/unit/s3.service.test.ts diff --git a/apps/backend/src/services/s3.service.ts b/apps/backend/src/services/s3.service.ts new file mode 100644 index 0000000..fd1a7b5 --- /dev/null +++ b/apps/backend/src/services/s3.service.ts @@ -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 { + 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 = { + '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 { + const s3File = this.client.file(key); + const arrayBuffer = await s3File.arrayBuffer(); + return Buffer.from(arrayBuffer); + } +} + +export const s3Service = new S3Service(); diff --git a/apps/backend/src/tests/unit/s3.service.test.ts b/apps/backend/src/tests/unit/s3.service.test.ts new file mode 100644 index 0000000..9f8eae0 --- /dev/null +++ b/apps/backend/src/tests/unit/s3.service.test.ts @@ -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?:\/\//); + }); + }); +}); + +