Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

백엔드 서버 Pre-Signed URL 반환하는 API 구현 / 프론트에서 해당 API 를 이용해서 이미지 upload 함수 구현 #106

Merged
merged 9 commits into from
Nov 13, 2024
Merged
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 14 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,31 @@ 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: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
useClass: TypeOrmConfigService,
}),
ThrottlerModule.forRoot([
{
ttl: 60000,
limit: 3,
},
]),
AuthModule,
UserModule,
PlaceModule,
MapModule,
CourseModule,
BannerModule,
AdminModule,
StorageModule,
],
controllers: [AppController],
providers: [
Expand All @@ -36,6 +45,10 @@ import { TimezoneInterceptor } from './config/TimezoneInterceptor';
provide: APP_INTERCEPTOR,
useClass: TimezoneInterceptor,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}
2 changes: 1 addition & 1 deletion backend/src/map/map.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,9 @@ export class MapService {
await this.mapRepository.save(map);

return {
savedPlaceId: placeId,
comment,
color,
placeId: placeId,
};
}

Expand Down
13 changes: 13 additions & 0 deletions backend/src/storage/dto/PreSignedPostRequest.ts
Original file line number Diff line number Diff line change
@@ -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()
Copy link
Collaborator

@Miensoap Miensoap Nov 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 이렇게도 가능해요!

const ALLOWED_FRUITS = ['apple', 'banana', 'cherry'] as const;

export class CreateFruitDto {
  @IsIn(ALLOWED_FRUITS, { message: 'Invalid fruit type' })
  fruit: string;
}

사실 이걸 생각하고 리뷰 남긴건데 똑같이 동작하니 괜찮습니다 ㅎㅎ 학습했다 치죠!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아하... 그러게요 엄청 어려웠네요..

extension: string;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3. 여기도 검증 추가할 수 있겠네요.
프론트엔드에 작성하신 상수 재활용해서 타입으로 가능할까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 좋습니다..

}
12 changes: 12 additions & 0 deletions backend/src/storage/exception/CloudFunctionsFetchException.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
18 changes: 18 additions & 0 deletions backend/src/storage/storage.constants.ts
Original file line number Diff line number Diff line change
@@ -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',
]);
18 changes: 18 additions & 0 deletions backend/src/storage/storage.controller.ts
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storage! 괜찮네요 ㅎㅎ

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

감사합니다!

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);
}
}
9 changes: 9 additions & 0 deletions backend/src/storage/storage.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
32 changes: 32 additions & 0 deletions backend/src/storage/storage.service.ts
Original file line number Diff line number Diff line change
@@ -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<string>('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);
});
}
}
25 changes: 25 additions & 0 deletions backend/src/storage/storage.validator.ts
Original file line number Diff line number Diff line change
@@ -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} 는 이미지 확장자가 아닙니다.`;
},
},
});
};
}
56 changes: 56 additions & 0 deletions frontend/src/api/image/index.ts
Original file line number Diff line number Diff line change
@@ -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<PreSignedURLResponse>(
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) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q. 여긴 다 function 키워드 대신 익명함수네요?
이유가 무엇인가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

api 의 다른 디렉터리에 있는 index.ts 모두 익명함수여서 일관성 유지 때문에 익명함수로 작성했습니다.

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);
});
};
23 changes: 23 additions & 0 deletions frontend/src/constants/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
16 changes: 16 additions & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Loading