diff --git a/backend/package.json b/backend/package.json index ee9ddef1..43857c94 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "cookie-parser": "^1.4.6", "@nestjs/platform-express": "^10.0.0", "@nestjs/typeorm": "^10.0.0", + "@nestjs/throttler": "^6.2.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "google-auth-library": "^9.14.2", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e3db7f41..60dc676c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,8 +12,10 @@ import { AuthModule } from './auth/auth.module'; import { UserModule } from './user/user.module'; import { BannerModule } from './banner/banner.module'; import { AdminModule } from './admin/admin.module'; -import { APP_INTERCEPTOR } from '@nestjs/core'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { TimezoneInterceptor } from './config/TimezoneInterceptor'; +import { StorageModule } from './storage/storage.module'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; @Module({ imports: [ @@ -21,6 +23,12 @@ import { TimezoneInterceptor } from './config/TimezoneInterceptor'; TypeOrmModule.forRootAsync({ useClass: TypeOrmConfigService, }), + ThrottlerModule.forRoot([ + { + ttl: 60000, + limit: 3, + }, + ]), AuthModule, UserModule, PlaceModule, @@ -28,6 +36,7 @@ import { TimezoneInterceptor } from './config/TimezoneInterceptor'; CourseModule, BannerModule, AdminModule, + StorageModule, ], controllers: [AppController], providers: [ @@ -36,6 +45,10 @@ import { TimezoneInterceptor } from './config/TimezoneInterceptor'; provide: APP_INTERCEPTOR, useClass: TimezoneInterceptor, }, + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, ], }) export class AppModule {} diff --git a/backend/src/map/map.service.ts b/backend/src/map/map.service.ts index da7c8b01..3d871f29 100644 --- a/backend/src/map/map.service.ts +++ b/backend/src/map/map.service.ts @@ -118,9 +118,9 @@ export class MapService { await this.mapRepository.save(map); return { - savedPlaceId: placeId, comment, color, + placeId: placeId, }; } diff --git a/backend/src/storage/dto/PreSignedPostRequest.ts b/backend/src/storage/dto/PreSignedPostRequest.ts new file mode 100644 index 00000000..b336bea7 --- /dev/null +++ b/backend/src/storage/dto/PreSignedPostRequest.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { IsImageFile } from '../storage.validator'; + +export class PreSignedPostRequest { + @IsString() + @IsNotEmpty() + dirName: string; + + @IsString() + @IsNotEmpty() + @IsImageFile() + extension: string; +} diff --git a/backend/src/storage/exception/CloudFunctionsFetchException.ts b/backend/src/storage/exception/CloudFunctionsFetchException.ts new file mode 100644 index 00000000..410ef059 --- /dev/null +++ b/backend/src/storage/exception/CloudFunctionsFetchException.ts @@ -0,0 +1,12 @@ +import { BaseException } from '../../common/exception/BaseException'; +import { HttpStatus } from '@nestjs/common'; + +export class CloudFunctionsFetchException extends BaseException { + constructor(error: Error) { + super({ + code: 777, + message: `cloud function 과 fetch 중 오류가 발생했습니다. : ${error.message}`, + status: HttpStatus.CONFLICT, + }); + } +} diff --git a/backend/src/storage/storage.constants.ts b/backend/src/storage/storage.constants.ts new file mode 100644 index 00000000..9cd1cf9c --- /dev/null +++ b/backend/src/storage/storage.constants.ts @@ -0,0 +1,18 @@ +export const IMAGE_EXTENSIONS = new Set([ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'bmp', + 'tiff', + 'tif', + 'webp', + 'svg', + 'heic', + 'raw', + 'cr2', + 'nef', + 'arw', + 'dng', + 'ico', +]); diff --git a/backend/src/storage/storage.controller.ts b/backend/src/storage/storage.controller.ts new file mode 100644 index 00000000..8ea2f7de --- /dev/null +++ b/backend/src/storage/storage.controller.ts @@ -0,0 +1,18 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { StorageService } from './storage.service'; +import { JwtAuthGuard } from '../auth/JwtAuthGuard'; +import { PreSignedPostRequest } from './dto/PreSignedPostRequest'; +import { Throttle } from '@nestjs/throttler'; + +@Controller('storage') +export class StorageController { + constructor(private readonly storageService: StorageService) {} + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @Post('/preSignedPost') + @UseGuards(JwtAuthGuard) + async getPreSignedPost(@Body() preSignedPostRequest: PreSignedPostRequest) { + const { dirName, extension } = preSignedPostRequest; + return await this.storageService.generatePreSignedPost(dirName, extension); + } +} diff --git a/backend/src/storage/storage.module.ts b/backend/src/storage/storage.module.ts new file mode 100644 index 00000000..2b02f697 --- /dev/null +++ b/backend/src/storage/storage.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { StorageController } from './storage.controller'; +import { StorageService } from './storage.service'; + +@Module({ + controllers: [StorageController], + providers: [StorageService], +}) +export class StorageModule {} diff --git a/backend/src/storage/storage.service.ts b/backend/src/storage/storage.service.ts new file mode 100644 index 00000000..43699169 --- /dev/null +++ b/backend/src/storage/storage.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CloudFunctionsFetchException } from './exception/CloudFunctionsFetchException'; + +@Injectable() +export class StorageService { + private preSignedPost: string; + + constructor(private configService: ConfigService) { + this.preSignedPost = this.configService.get('PRE_SIGNED_POST'); + } + + async generatePreSignedPost(dirName: string, extension: string) { + return fetch(this.preSignedPost, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + dirName: dirName, + extension: extension, + }), + }) + .then((res) => res.json()) + .then((data) => { + return data; + }) + .catch((err) => { + throw new CloudFunctionsFetchException(err); + }); + } +} diff --git a/backend/src/storage/storage.validator.ts b/backend/src/storage/storage.validator.ts new file mode 100644 index 00000000..0473c2bf --- /dev/null +++ b/backend/src/storage/storage.validator.ts @@ -0,0 +1,25 @@ +import { + registerDecorator, + ValidationArguments, + ValidatorOptions, +} from 'class-validator'; +import { IMAGE_EXTENSIONS } from './storage.constants'; + +export function IsImageFile(validationOptions?: ValidatorOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'IsImageFile', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any) { + return IMAGE_EXTENSIONS.has(value); + }, + defaultMessage(validationArguments?: ValidationArguments): string { + return `${validationArguments.value} 는 이미지 확장자가 아닙니다.`; + }, + }, + }); + }; +} diff --git a/frontend/src/api/image/index.ts b/frontend/src/api/image/index.ts new file mode 100644 index 00000000..0ef1e3af --- /dev/null +++ b/frontend/src/api/image/index.ts @@ -0,0 +1,56 @@ +import { axiosInstance } from '../axiosInstance'; +import { PreSignedURLResponse } from '../../types'; +import { END_POINTS, IMAGE_EXTENSIONS } from '@/constants/api'; +import { THREE_MB } from '../../constants/api'; + +export const generatePreSignedPost = async ( + dirName: string, + extension: string, +) => { + const { data } = await axiosInstance.post( + END_POINTS.PRE_SIGNED_POST, + { + dirName: dirName, + extension: extension, + }, + ); + return data; +}; + +export const getExtensionByFile = (file: File) => { + const extension = file.name.split('.').pop(); + return extension ? extension.toLowerCase() : null; +}; + +export const validateFile = (file: File, extension: string | null) => { + return !( + !extension || + !IMAGE_EXTENSIONS.has(extension) || + file.size > THREE_MB + ); +}; + +export const uploadImage = async (file: File, dirName: string) => { + const extension = getExtensionByFile(file); + if (!validateFile(file, extension)) { + throw new Error( + '지원되지 않는 파일 형식이거나 파일 크기가 3MB를 초과합니다.', + ); + } + const preSignedPost = await generatePreSignedPost(dirName, extension!); + const formData = new FormData(); + Object.entries(preSignedPost.fields).forEach(([key, value]) => { + formData.append(key, value); + }); + formData.append('file', file); + return fetch(preSignedPost.url, { + method: 'POST', + body: formData, + }) + .then(() => { + return preSignedPost.uploadedUrl; + }) + .catch((err) => { + throw new Error(err); + }); +}; diff --git a/frontend/src/constants/api.ts b/frontend/src/constants/api.ts index 00f590fb..726edf1b 100644 --- a/frontend/src/constants/api.ts +++ b/frontend/src/constants/api.ts @@ -17,4 +17,27 @@ export const END_POINTS = { GOOGLE_LOGIN: '/oauth/google/signIn', MY_MAP: '/maps/my', PLACE: '/places', + IMAGES: '/images', + PRE_SIGNED_POST: '/storage/preSignedPost', }; + +export const IMAGE_EXTENSIONS = new Set([ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'bmp', + 'tiff', + 'tif', + 'webp', + 'svg', + 'heic', + 'raw', + 'cr2', + 'nef', + 'arw', + 'dng', + 'ico', +]); + +export const THREE_MB = 3145728; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index cae044d3..97edb828 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -50,3 +50,19 @@ export type PlaceMarker = { color: string; category: string; }; + +export type PreSignedURLResponse = { + fields: { + 'Content-Type': string; + Policy: string; + 'X-Amz-Algorithm': string; + 'X-Amz-Credential': string; + 'X-Amz-Date': string; + 'X-Amz-Signature': string; + acl: string; + bucket: string; + key: string; + }; + uploadedUrl: string; + url: string; +};