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

presigned url 적용 #147

Merged
merged 14 commits into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"class-validator": "^0.14.0",
"crypto-js": "^4.2.0",
"express": "^4.18.2",
"fluent-ffmpeg": "^2.1.2",
"lodash": "^4.17.21",
"mongoose": "^8.0.0",
"reflect-metadata": "^0.1.13",
Expand Down
2 changes: 1 addition & 1 deletion server/src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class AppController {
/**
* 광고 이미지 응답
*/
@ApiTags('COMPLETE')
@ApiTags('LEGACY')
Copy link
Collaborator

Choose a reason for hiding this comment

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

LEGACY 태그로 기존 코드 구분하신게 좋은거 같아요!

@Get('ads')
@ApiSuccessResponse(200, '광고 조회 성공', AdsResponseDto)
@ApiFailResponse('인증 실패', [InvalidTokenException, TokenExpiredException])
Expand Down
8 changes: 6 additions & 2 deletions server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { ActionModule } from './action/action.module';
import { UserModule } from './user/user.module';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PresignedUrlController } from './presigned-url/presigned-url.controller';
import { PresignedUrlService } from './presigned-url/presigned-url.service';
import { PresignedUrlModule } from './presigned-url/presigned-url.module';

@Module({
imports: [
Expand All @@ -16,8 +19,9 @@ import { AppService } from './app.service';
VideoModule,
ActionModule,
UserModule,
PresignedUrlModule,
],
controllers: [AppController],
providers: [AppService],
controllers: [AppController, PresignedUrlController],
providers: [AppService, PresignedUrlService],
})
export class AppModule {}
15 changes: 5 additions & 10 deletions server/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
Controller,
Post,
Body,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { Controller, Post, Body, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
import { ApiSuccessResponse } from 'src/decorators/api-succes-response';
Expand All @@ -21,7 +15,6 @@ import { SigninRequestDto } from './dto/signin-request.dto';
import { RefreshRequestDto } from './dto/refresh-request.dto';
import { RefreshResponseDto } from './dto/refresh-response.dto';

@ApiTags('COMPLETE')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
Expand All @@ -30,22 +23,23 @@ export class AuthController {
* 회원가입
*/
@Post('signup')
@ApiTags('AUTH')
@ApiConsumes('multipart/form-data')
@ApiSuccessResponse(201, '회원가입 성공', SignupResponseDto)
@ApiFailResponse('인증 실패', [OAuthFailedException])
@ApiFailResponse('회원가입 실패', [UserConflictException])
@UseInterceptors(FileInterceptor('profileImage'))
signUp(
@UploadedFile() profileImage: Express.Multer.File,
@Body() signupRequestDto: SignupRequestDto,
): Promise<SignupResponseDto> {
return this.authService.create(signupRequestDto, profileImage);
return this.authService.create(signupRequestDto);
}

/**
* 로그인
*/
@Post('login')
@ApiTags('AUTH')
@ApiSuccessResponse(201, '로그인 성공', SigninResponseDto)
@ApiFailResponse('인증 실패', [LoginFailException, OAuthFailedException])
signin(
Expand All @@ -58,6 +52,7 @@ export class AuthController {
* 토큰 재발급
*/
@Post('refresh')
@ApiTags('AUTH')
@ApiSuccessResponse(201, '토큰 재발급 성공', RefreshResponseDto)
@ApiFailResponse('인증 실패', [InvalidRefreshTokenException])
refresh(
Expand Down
19 changes: 4 additions & 15 deletions server/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { UserConflictException } from 'src/exceptions/conflict.exception';
import { putObject } from 'src/ncpAPI/putObject';
import { User, UserDocument } from 'src/user/schemas/user.schema';
import { JwtService } from '@nestjs/jwt';
import { LoginFailException } from 'src/exceptions/login-fail.exception';
Expand All @@ -23,24 +22,14 @@ export class AuthService {
private jwtService: JwtService,
) {}

async create(
signupRequestDto: SignupRequestDto,
profileImage: Express.Multer.File,
): Promise<SignupResponseDto> {
async create(signupRequestDto: SignupRequestDto): Promise<SignupResponseDto> {
const { uuid } = signupRequestDto;
if (await this.UserModel.findOne({ uuid })) {
throw new UserConflictException();
}
// TODO 프로필 이미지 예외처리
const profileImageExtension = profileImage
? profileImage.originalname.split('.').pop()
: null;
if (profileImage) {
putObject(
process.env.PROFILE_BUCKET,
`${uuid}.${profileImageExtension}`,
profileImage.buffer,
);
const profileImageExtension = signupRequestDto.profileExtension;
if (profileImageExtension) {
// TODO 프로필 이미지 업로드 됐는지 확인
}

const newUser = new this.UserModel({
Expand Down
32 changes: 32 additions & 0 deletions server/src/ncpAPI/presignedURL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { HttpRequest } from '@smithy/protocol-http';
import { S3RequestPresigner } from '@aws-sdk/s3-request-presigner';
import { parseUrl } from '@smithy/url-parser';
import { formatUrl } from '@aws-sdk/util-format-url';
import { Hash } from '@smithy/hash-node';

export const createPresignedUrl = async (
bucketName: string,
objectName: string,
method: string,
) => {
const region = 'kr-standard';
const endPoint = 'https://kr.object.ncloudstorage.com';
const canonicalURI = `/${bucketName}/${objectName}`;
const apiUrl = `${endPoint}${canonicalURI}`;
const url = parseUrl(apiUrl);

const presigner = new S3RequestPresigner({
credentials: {
accessKeyId: process.env.ACCESS_KEY,
secretAccessKey: process.env.SECRET_KEY,
},
region,
sha256: Hash.bind(null, 'sha256'),
});

const signedUrlObject = await presigner.presign(
new HttpRequest({ ...url, method }),
{ expiresIn: 100 },
);
return { name: objectName, url: formatUrl(signedUrlObject) };
Copy link
Collaborator

Choose a reason for hiding this comment

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

url만 리턴하는게 자연스러울거 같은데 name을 같이 리턴해야만 하는 이유가 있을까요?
이 함수를 호출하는 코드에서도 url만 사용하는 경우가 있는 것 같아서요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

광고 이미지를 재발급 받으려면 name이 필요한데 서비스에서 추가하기 귀찮아서 이렇게 했습니다..!

};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class AdvertisementPresignedUrlRequestDto {
/**
* 특정 광고 이미지의 presigned url만 받고 싶은 경우
* @example 'test.webp'
Comment on lines +3 to +4
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

*/
name?: string;
}
15 changes: 15 additions & 0 deletions server/src/presigned-url/dto/profile-presigned-url-request.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { IsUUID } from 'class-validator';

export class ProfilePresignedUrlRequestDto {
/**
* user uuid
*/
@IsUUID()
uuid: string;

/**
* 프로필 이미지 확장자
* @example 'webp'
*/
profileExtension: string;
}
21 changes: 21 additions & 0 deletions server/src/presigned-url/dto/reissue-presigned-url-request.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { IsEnum } from 'class-validator';

enum TypeEnum {
thumbnail = 'thumbnail',
profile = 'profile',
}

export class ReissuePresignedUrlRequestDto {
/**
* 요청하는 이미지 종류
* @example 'thumbnail'
*/
@IsEnum(TypeEnum)
type: TypeEnum;

/**
* 이미지 확장자
* @example 'webp'
*/
extension: string;
}
13 changes: 13 additions & 0 deletions server/src/presigned-url/dto/video-presigned-url-request.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export class VIdeoPresignedUrlRequestDto {
/**
* 비디오 확장자
* @example 'mp4'
*/
videoExtension: string;

/**
* 썸네일 이미지 확장자
* @example 'webp'
*/
thumbnailExtension: string;
}
57 changes: 57 additions & 0 deletions server/src/presigned-url/presigned-url.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ApiFailResponse } from 'src/decorators/api-fail-response';
import { AuthGuard } from 'src/auth/auth.guard';
import { InvalidTokenException } from 'src/exceptions/invalid-token.exception';
import { TokenExpiredException } from 'src/exceptions/token-expired.exception';
import { PresignedUrlService } from './presigned-url.service';
import { AdvertisementPresignedUrlRequestDto } from './dto/advertisement-presigned-url-request.dto';
import { ProfilePresignedUrlRequestDto } from './dto/profile-presigned-url-request.dto';
import { VIdeoPresignedUrlRequestDto } from './dto/video-presigned-url-request.dto';
import { ReissuePresignedUrlRequestDto } from './dto/reissue-presigned-url-request.dto';

@ApiTags('PRESIGNED URL')
@ApiBearerAuth()
@UseGuards(AuthGuard)
@ApiFailResponse('인증 실패', [InvalidTokenException, TokenExpiredException])
@Controller('presigned-url')
export class PresignedUrlController {
constructor(private presignedUrlService: PresignedUrlService) {}

/**
* 광고 이미지를 GET하는 url 발급
*/
@Get('advertisements')
getAdvertisementPresignedUrl(
@Query() query: AdvertisementPresignedUrlRequestDto,
) {
return this.presignedUrlService.getAdvertisementPresignedUrl(query.name);
}

/**
* 프로필 이미지를 PUT하는 url 발급
*/
@Get('profile')
putProfilePresignedUrl(@Query() query: ProfilePresignedUrlRequestDto) {
return this.presignedUrlService.putProfilePresignedUrl(query);
}

/**
* 비디오, 썸네일 이미지를 PUT하는 url 발급
*/
@Get('video')
putVideoPresignedUrl(@Query() query: VIdeoPresignedUrlRequestDto) {
return this.presignedUrlService.putVideoPresignedUrl(query);
}

/**
* 만료된 presigned url 재발급
*/
@Get('reissue/:id')
getImagePresignedUrl(
@Param('id') id: string,
@Query() query: ReissuePresignedUrlRequestDto,
) {
return this.presignedUrlService.getImagePresignedUrl(id, query);
}
}
4 changes: 4 additions & 0 deletions server/src/presigned-url/presigned-url.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Module } from '@nestjs/common';

@Module({})
export class PresignedUrlModule {}
75 changes: 75 additions & 0 deletions server/src/presigned-url/presigned-url.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { createPresignedUrl } from 'src/ncpAPI/presignedURL';
import { listObjects } from 'src/ncpAPI/listObjects';
import * as _ from 'lodash';
import { xml2js } from 'xml-js';
import { Types } from 'mongoose';

@Injectable()
export class PresignedUrlService {
async getAdvertisementPresignedUrl(adName: string) {
if (adName)
return createPresignedUrl(
process.env.ADVERTISEMENT_BUCKET,
adName,
'GET',
);

const xmlData = await listObjects(process.env.ADVERTISEMENT_BUCKET);
const jsonData: any = xml2js(xmlData, { compact: true });

const adList = _.map(jsonData.ListBucketResult.Contents, 'Key._text');
const advertisements = await Promise.all(
adList.map(async (advertisement: string) => {
return createPresignedUrl(
process.env.ADVERTISEMENT_BUCKET,
advertisement,
'GET',
);
}),
);
return { advertisements };
}

async putProfilePresignedUrl({ uuid, profileExtension }) {
const objectName = `${uuid}.${profileExtension}`;
const presignedUrl = (
await createPresignedUrl(process.env.PROFILE_BUCKET, objectName, 'PUT')
).url;
return { presignedUrl };
}

async putVideoPresignedUrl({ videoExtension, thumbnailExtension }) {
const videoId = new Types.ObjectId();
const [videoUrl, thumbnailUrl] = await Promise.all([
(
await createPresignedUrl(
process.env.INPUT_BUCKET,
`${videoId}.${videoExtension}`,
'PUT',
)
).url,
Copy link
Collaborator

Choose a reason for hiding this comment

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

아래 코드는 어떠신가요?

 createPresignedUrl(
     process.env.INPUT_BUCKET,
     `${videoId}.${videoExtension}`,
     'PUT',
).then( data=> data.url)

(
await createPresignedUrl(
process.env.THUMBNAIL_BUCKET,
`${videoId}.${thumbnailExtension}`,
'PUT',
)
).url,
]);
return { videoId, videoUrl, thumbnailUrl };
}

async getImagePresignedUrl(id: string, { type, extension }) {
// TODO 업로드 된 이미지인지 확인
const bucketName = {
thumbnail: process.env.THUMBNAIL_BUCKET,
profile: process.env.PROFILE_BUCKET,
}[type];
const objectName = `${id}.${extension}`;
const presignedUrl = (
await createPresignedUrl(bucketName, objectName, 'GET')
).url;
return { presignedUrl };
}
}
19 changes: 19 additions & 0 deletions server/src/user/dto/profile-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export class ProfileResponseDto {
/**
* 닉네임
* @example 'honux'
*/
nickname: string;

/**
* 상태 메세지
* @example 'web be 마스터'
*/
statusMessage: string;

/**
* 프로필 이미지 가져오는 presigned url
* @example 'https://ncloud.com/'
*/
profileImageUrl: string;
}
4 changes: 3 additions & 1 deletion server/src/user/dto/profile.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { PickType } from '@nestjs/swagger';
import { UserDto } from './user.dto';

export class ProfileDto extends PickType(UserDto, ['profileImage'] as const) {
export class ProfileDto extends PickType(UserDto, [
'profileExtension',
] as const) {
constructor(init: ProfileDto) {
super();
Object.assign(this, init);
Expand Down
Loading