diff --git a/server/.gitignore b/server/.gitignore index a2e6062a..43d03da3 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -35,3 +35,7 @@ lerna-debug.log* !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json + +src/test.py +src/video/common.response.dto.ts +src/video/video.decorator.ts \ No newline at end of file diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index a35aac1d..68b16ecb 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -1,5 +1,4 @@ -import { Controller, Post, Body, UseInterceptors } from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; +import { Controller, Post, Body, Get, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ApiSuccessResponse } from 'src/decorators/api-succes-response'; import { ApiFailResponse } from 'src/decorators/api-fail-response'; @@ -8,6 +7,9 @@ import { OAuthFailedException } from 'src/exceptions/oauth-failed.exception'; import { LoginFailException } from 'src/exceptions/login-fail.exception'; import { InvalidRefreshTokenException } from 'src/exceptions/invalid-refresh-token.exception'; import { ProfileUploadRequiredException } from 'src/exceptions/profile-upload-required-exception'; +import { PresignedUrlResponseDto } from 'src/presigned-url/dto/presigned-url-response.dto'; +import { PresignedUrlService } from 'src/presigned-url/presigned-url.service'; +import { SignupProfilePresignedUrlRequestDto } from 'src/presigned-url/dto/signup-profile-presigned-url-request.dto'; import { AuthService } from './auth.service'; import { SignupRequestDto } from './dto/signup-request.dto'; import { SignupResponseDto } from './dto/signup-response.dto'; @@ -18,7 +20,10 @@ import { RefreshResponseDto } from './dto/refresh-response.dto'; @Controller('auth') export class AuthController { - constructor(private authService: AuthService) {} + constructor( + private authService: AuthService, + private presignedUrlService: PresignedUrlService, + ) {} /** * 회원가입 @@ -60,4 +65,23 @@ export class AuthController { ): Promise { return this.authService.refresh(refreshRequestDto); } + + /** + * 회원가입 시 프로필 이미지를 PUT하는 url 발급 + */ + @Get('signup/presigned-url/profile') + @ApiSuccessResponse( + 200, + '프로필 이미지를 업로드하는 url 발급 성공', + PresignedUrlResponseDto, + ) + async putProfilePresignedUrl( + @Query() query: SignupProfilePresignedUrlRequestDto, + ) { + await this.authService.checkUserConflict(query.uuid); + return this.presignedUrlService.putProfilePresignedUrl( + query.uuid, + query.profileExtension, + ); + } } diff --git a/server/src/auth/auth.module.ts b/server/src/auth/auth.module.ts index 5765d4de..c722258e 100644 --- a/server/src/auth/auth.module.ts +++ b/server/src/auth/auth.module.ts @@ -3,6 +3,7 @@ import { MongooseModule } from '@nestjs/mongoose'; import { User, UserSchema } from 'src/user/schemas/user.schema'; import { JwtModule } from '@nestjs/jwt'; import { ConfigModule } from '@nestjs/config'; +import { PresignedUrlService } from 'src/presigned-url/presigned-url.service'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; @@ -17,6 +18,6 @@ import { AuthService } from './auth.service'; }), ], controllers: [AuthController], - providers: [AuthService], + providers: [AuthService, PresignedUrlService], }) export class AuthModule {} diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index 89ba7e54..344b65f5 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -24,11 +24,15 @@ export class AuthService { private jwtService: JwtService, ) {} - async create(signupRequestDto: SignupRequestDto): Promise { - const { uuid, profileImageExtension } = signupRequestDto; + async checkUserConflict(uuid: string): Promise { if (await this.UserModel.findOne({ uuid })) { throw new UserConflictException(); } + } + + async create(signupRequestDto: SignupRequestDto): Promise { + const { uuid, profileImageExtension } = signupRequestDto; + await this.checkUserConflict(uuid); if ( profileImageExtension && !(await checkUpload( diff --git a/server/src/exceptions/bad-request-format.exception.ts b/server/src/exceptions/bad-request-format.exception.ts new file mode 100644 index 00000000..58ddd2e2 --- /dev/null +++ b/server/src/exceptions/bad-request-format.exception.ts @@ -0,0 +1,9 @@ +import { HttpStatus } from '@nestjs/common'; +import { ErrorCode } from 'src/exceptions/enum/exception.enum'; +import { BaseException } from './base.exception'; + +export class BadRequestFormatException extends BaseException { + constructor() { + super(ErrorCode.BadRequestFormat, HttpStatus.BAD_REQUEST); + } +} diff --git a/server/src/exceptions/enum/exception.enum.ts b/server/src/exceptions/enum/exception.enum.ts index 689ae2e8..e948621b 100644 --- a/server/src/exceptions/enum/exception.enum.ts +++ b/server/src/exceptions/enum/exception.enum.ts @@ -1,5 +1,6 @@ enum ErrorCode { UserConflict = 3001, + VideoConflict = 3002, BadRequest = 2000, LoginFail = 1000, TokenExpired = 1001, @@ -17,10 +18,12 @@ enum ErrorCode { VideoUploadRequired = 5004, BadVideoFormat = 8000, BadThumbnailFormat = 8100, + BadRequestFormat = 8200, } const ErrorMessage = { [ErrorCode.UserConflict]: '이미 가입된 회원', + [ErrorCode.VideoConflict]: '중복된 Video Id', [ErrorCode.BadRequest]: '잘못된 요청 형식', [ErrorCode.LoginFail]: '가입되지 않은 회원', [ErrorCode.TokenExpired]: 'AccessToken 만료', @@ -33,10 +36,11 @@ const ErrorMessage = { [ErrorCode.NeverViewVideo]: '시청한 영상만 별점을 등록할 수 있음', [ErrorCode.VideoNotFound]: '비디오를 찾을 수 없음', [ErrorCode.UserNotFound]: '유저를 찾을 수 없음', - [ErrorCode.ObjectNotFound]: '오브젝트를 찾을 수 없음', + [ErrorCode.ObjectNotFound]: '파일을 찾을 수 없음', [ErrorCode.ProfileUploadRequired]: '프로필 이미지를 먼저 업로드 해야합니다.', [ErrorCode.ThumbnailUploadRequired]: '썸네일을 먼저 업로드 해야합니다.', [ErrorCode.VideoUploadRequired]: '비디오를 먼저 업로드 해야합니다.', + [ErrorCode.BadRequestFormat]: '요청 형식이 잘못됨', }; export { ErrorCode, ErrorMessage }; diff --git a/server/src/exceptions/video-conflict.exception.ts b/server/src/exceptions/video-conflict.exception.ts new file mode 100644 index 00000000..922bbb23 --- /dev/null +++ b/server/src/exceptions/video-conflict.exception.ts @@ -0,0 +1,9 @@ +import { HttpStatus } from '@nestjs/common'; +import { ErrorCode } from 'src/exceptions/enum/exception.enum'; +import { BaseException } from './base.exception'; + +export class VideoConflictException extends BaseException { + constructor() { + super(ErrorCode.VideoConflict, HttpStatus.CONFLICT); + } +} diff --git a/server/src/presigned-url/dto/advertisement-presigned-url-request.dto.ts b/server/src/presigned-url/dto/advertisement-presigned-url-request.dto.ts index d817b838..741ab5f2 100644 --- a/server/src/presigned-url/dto/advertisement-presigned-url-request.dto.ts +++ b/server/src/presigned-url/dto/advertisement-presigned-url-request.dto.ts @@ -1,7 +1,11 @@ +import { IsNotEmpty, IsOptional } from 'class-validator'; + export class AdvertisementPresignedUrlRequestDto { /** * 특정 광고 이미지의 presigned url만 받고 싶은 경우 * @example 'test.webp' */ + @IsOptional() + @IsNotEmpty() name?: string; } diff --git a/server/src/presigned-url/dto/profile-presigned-url-request.dto.ts b/server/src/presigned-url/dto/profile-presigned-url-request.dto.ts index 4b052285..cc059a29 100644 --- a/server/src/presigned-url/dto/profile-presigned-url-request.dto.ts +++ b/server/src/presigned-url/dto/profile-presigned-url-request.dto.ts @@ -1,15 +1,10 @@ -import { IsUUID } from 'class-validator'; +import { IsNotEmpty } from 'class-validator'; export class ProfilePresignedUrlRequestDto { - /** - * user uuid - */ - @IsUUID() - uuid: string; - /** * 프로필 이미지 확장자 * @example 'webp' */ + @IsNotEmpty() profileExtension: string; } diff --git a/server/src/presigned-url/dto/reissue-presigned-url-request.dto.ts b/server/src/presigned-url/dto/reissue-presigned-url-request.dto.ts index 341d81a6..f96e42c9 100644 --- a/server/src/presigned-url/dto/reissue-presigned-url-request.dto.ts +++ b/server/src/presigned-url/dto/reissue-presigned-url-request.dto.ts @@ -1,4 +1,4 @@ -import { IsEnum } from 'class-validator'; +import { IsEnum, IsNotEmpty } from 'class-validator'; enum TypeEnum { thumbnail = 'thumbnail', @@ -17,5 +17,6 @@ export class ReissuePresignedUrlRequestDto { * 이미지 확장자 * @example 'webp' */ + @IsNotEmpty() extension: string; } diff --git a/server/src/presigned-url/dto/signup-profile-presigned-url-request.dto.ts b/server/src/presigned-url/dto/signup-profile-presigned-url-request.dto.ts new file mode 100644 index 00000000..ec387dde --- /dev/null +++ b/server/src/presigned-url/dto/signup-profile-presigned-url-request.dto.ts @@ -0,0 +1,18 @@ +import { IsNotEmpty, IsUUID } from 'class-validator'; +import { ProfilePresignedUrlRequestDto } from './profile-presigned-url-request.dto'; + +export class SignupProfilePresignedUrlRequestDto extends ProfilePresignedUrlRequestDto { + /** + * 유저 ID + * @example '550e8400-e29b-41d4-a716-446655440000' + */ + @IsUUID() + uuid: string; + + /** + * 소셜 accessToken + * @example '1/fFAGRNJru1FTz70BzhT3Zg' + */ + @IsNotEmpty() + accessToken: string; +} diff --git a/server/src/presigned-url/dto/video-presigned-url-request.dto.ts b/server/src/presigned-url/dto/video-presigned-url-request.dto.ts index 4e93c1a3..a4fa2f25 100644 --- a/server/src/presigned-url/dto/video-presigned-url-request.dto.ts +++ b/server/src/presigned-url/dto/video-presigned-url-request.dto.ts @@ -1,13 +1,17 @@ +import { IsNotEmpty } from 'class-validator'; + export class VIdeoPresignedUrlRequestDto { /** * 비디오 확장자 * @example 'mp4' */ + @IsNotEmpty() videoExtension: string; /** * 썸네일 이미지 확장자 * @example 'webp' */ + @IsNotEmpty() thumbnailExtension: string; } diff --git a/server/src/presigned-url/presigned-url.controller.ts b/server/src/presigned-url/presigned-url.controller.ts index 6d9e37d5..8c8bbfbc 100644 --- a/server/src/presigned-url/presigned-url.controller.ts +++ b/server/src/presigned-url/presigned-url.controller.ts @@ -5,6 +5,8 @@ import { AuthGuard } from 'src/auth/auth.guard'; import { InvalidTokenException } from 'src/exceptions/invalid-token.exception'; import { TokenExpiredException } from 'src/exceptions/token-expired.exception'; import { ApiSuccessResponse } from 'src/decorators/api-succes-response'; +import { RequestUser, User } from 'src/decorators/request-user'; +import { ObjectNotFoundException } from 'src/exceptions/object-not-found.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'; @@ -31,6 +33,7 @@ export class PresignedUrlController { '광고 이미지 가져오는 url 발급 성공', AdvertisementPresignedUrlResponseDto, ) + @ApiFailResponse('url 발급 실패', [ObjectNotFoundException]) getAdvertisementPresignedUrl( @Query() query: AdvertisementPresignedUrlRequestDto, ) { @@ -38,7 +41,7 @@ export class PresignedUrlController { } /** - * 프로필 이미지를 PUT하는 url 발급 + * 프로필 이미지 변경 시 이미지를 PUT하는 url 발급 */ @Get('profile') @ApiSuccessResponse( @@ -46,8 +49,14 @@ export class PresignedUrlController { '프로필 이미지를 업로드하는 url 발급 성공', PresignedUrlResponseDto, ) - putProfilePresignedUrl(@Query() query: ProfilePresignedUrlRequestDto) { - return this.presignedUrlService.putProfilePresignedUrl(query); + putProfilePresignedUrl( + @Query() query: ProfilePresignedUrlRequestDto, + @RequestUser() user: User, + ) { + return this.presignedUrlService.putProfilePresignedUrl( + user.id, + query.profileExtension, + ); } /** @@ -72,6 +81,7 @@ export class PresignedUrlController { description: '썸네일 재발급 시 비디오ID, 프로필 재발급 시 유저 UUID', }) @ApiSuccessResponse(200, 'presigned url 재발급 성공', PresignedUrlResponseDto) + @ApiFailResponse('url 발급 실패', [ObjectNotFoundException]) getImagePresignedUrl( @Param('id') id: string, @Query() query: ReissuePresignedUrlRequestDto, diff --git a/server/src/presigned-url/presigned-url.service.ts b/server/src/presigned-url/presigned-url.service.ts index 9a0a8c9c..7d5012e1 100644 --- a/server/src/presigned-url/presigned-url.service.ts +++ b/server/src/presigned-url/presigned-url.service.ts @@ -41,10 +41,10 @@ export class PresignedUrlService { return { advertisements }; } - async putProfilePresignedUrl({ - uuid, - profileExtension, - }): Promise { + async putProfilePresignedUrl( + uuid: string, + profileExtension: string, + ): Promise { const objectName = `${uuid}.${profileExtension}`; const presignedUrl = await createPresignedUrl( process.env.PROFILE_BUCKET, diff --git a/server/src/user/dto/rated-video-request.dto.ts b/server/src/user/dto/rated-video-request.dto.ts index d061e796..08aca164 100644 --- a/server/src/user/dto/rated-video-request.dto.ts +++ b/server/src/user/dto/rated-video-request.dto.ts @@ -1,4 +1,4 @@ -import { IsInt, IsPositive } from 'class-validator'; +import { IsInt, IsPositive, IsOptional, IsNotEmpty } from 'class-validator'; export class UserRatedVideoQueryDto { /** @@ -11,5 +11,7 @@ export class UserRatedVideoQueryDto { /** * 마지막으로 조회한 비디오의 ratedAt */ + @IsOptional() + @IsNotEmpty() lastRatedAt?: string; } diff --git a/server/src/user/dto/uploaded-video-request.dto.ts b/server/src/user/dto/uploaded-video-request.dto.ts index 077457c1..3efc5de9 100644 --- a/server/src/user/dto/uploaded-video-request.dto.ts +++ b/server/src/user/dto/uploaded-video-request.dto.ts @@ -1,4 +1,4 @@ -import { IsInt, IsPositive } from 'class-validator'; +import { IsInt, IsNotEmpty, IsOptional, IsPositive } from 'class-validator'; export class UserUploadedVideoQueryDto { /** @@ -11,5 +11,7 @@ export class UserUploadedVideoQueryDto { /** * 마지막으로 조회한 비디오 ID */ + @IsOptional() + @IsNotEmpty() lastId?: string; } diff --git a/server/src/video/dto/video-rating.dto.ts b/server/src/video/dto/video-rating.dto.ts index a96fbfaa..7caf8580 100644 --- a/server/src/video/dto/video-rating.dto.ts +++ b/server/src/video/dto/video-rating.dto.ts @@ -6,7 +6,7 @@ export class VideoRatingDTO { * @example 2 */ @IsNumber() - @Min(1) + @Min(0) @Max(5) rating: number; diff --git a/server/src/video/video.controller.ts b/server/src/video/video.controller.ts index 66a0259d..e67b7ef0 100644 --- a/server/src/video/video.controller.ts +++ b/server/src/video/video.controller.ts @@ -12,6 +12,7 @@ import { Query, } from '@nestjs/common'; import { + ApiBadRequestResponse, ApiBearerAuth, ApiOkResponse, ApiProduces, @@ -30,10 +31,13 @@ import { ActionService } from 'src/action/action.service'; import { NeverViewVideoException } from 'src/exceptions/never-view-video.exception'; import { IgnoreInterceptor } from 'src/decorators/ignore-interceptor'; import { SeedQueryDto } from 'src/action/dto/manifest-query.dto'; +import { VideoConflictException } from 'src/exceptions/video-conflict.exception'; +import { ThumbnailUploadRequiredException } from 'src/exceptions/thumbnail-upload-required-exception copy 2'; +import { VideoUploadRequiredException } from 'src/exceptions/video-upload-required-exception copy'; +import { BadRequestFormatException } from 'src/exceptions/bad-request-format.exception'; import { VideoService } from './video.service'; import { VideoDto } from './dto/video.dto'; import { VideoRatingDTO } from './dto/video-rating.dto'; -import { FileExtensionPipe } from './video.pipe'; import { RandomVideoQueryDto } from './dto/random-video-query.dto'; import { VideoSummaryResponseDto } from './dto/video-summary-response.dto'; import { VideoInfoDto } from './dto/video-info.dto'; @@ -51,7 +55,6 @@ export class VideoController { constructor( private videoService: VideoService, private actionService: ActionService, - private fileExtensionPipe: FileExtensionPipe, ) {} /** @@ -77,6 +80,12 @@ export class VideoController { ) @Post(':videoId') @ApiSuccessResponse(201, '비디오 업로드 성공', VideoSummaryResponseDto) + @ApiFailResponse('중복된 비디오 ID', [VideoConflictException]) + @ApiFailResponse('잘못된 비디오 ID', [BadRequestFormatException]) + @ApiFailResponse('업로드가 필요함', [ + VideoUploadRequiredException, + ThumbnailUploadRequiredException, + ]) uploadVideo( @Body() videoDto: VideoDto, @RequestUser() user: User, diff --git a/server/src/video/video.service.ts b/server/src/video/video.service.ts index 4ccc5c72..17a49905 100644 --- a/server/src/video/video.service.ts +++ b/server/src/video/video.service.ts @@ -3,7 +3,7 @@ /* eslint-disable class-methods-use-this */ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Model } from 'mongoose'; +import { Model, Types } from 'mongoose'; import { requestEncoding } from 'src/ncpAPI/requestEncoding'; import { User } from 'src/user/schemas/user.schema'; import { deleteObject } from 'src/ncpAPI/deleteObject'; @@ -13,6 +13,11 @@ import axios from 'axios'; import * as _ from 'lodash'; import { ActionService } from 'src/action/action.service'; import { createPresignedUrl } from 'src/ncpAPI/presignedURL'; +import { VideoConflictException } from 'src/exceptions/video-conflict.exception'; +import { checkUpload } from 'src/ncpAPI/listObjects'; +import { VideoUploadRequiredException } from 'src/exceptions/video-upload-required-exception copy'; +import { ThumbnailUploadRequiredException } from 'src/exceptions/thumbnail-upload-required-exception copy 2'; +import { BadRequestFormatException } from 'src/exceptions/bad-request-format.exception'; import { VideoDto } from './dto/video.dto'; import { Video } from './schemas/video.schema'; import { CategoryEnum } from './enum/category.enum'; @@ -150,10 +155,20 @@ export class VideoService { } async uploadVideo(videoDto: VideoDto, uuid: string, videoId: string) { + if (!Types.ObjectId.isValid(videoId)) throw new BadRequestFormatException(); + const checkDuplicate = await this.VideoModel.findOne({ _id: videoId }); + if (checkDuplicate) throw new VideoConflictException(); + const { videoExtension, thumbnailExtension } = videoDto; const videoName = `${videoId}.${videoExtension}`; const thumbnailName = `${videoId}.${thumbnailExtension}`; - // TODO 비디오, 썸네일 업로드 확인 + + if (!(await checkUpload(process.env.INPUT_BUCKET, videoName))) { + throw new VideoUploadRequiredException(); + } + if (!(await checkUpload(process.env.THUMBNAIL_BUCKET, thumbnailName))) { + throw new ThumbnailUploadRequiredException(); + } await requestEncoding(process.env.INPUT_BUCKET, [videoName]);