diff --git a/nginx.conf b/nginx.conf index 08d95c05..35b3eb85 100644 --- a/nginx.conf +++ b/nginx.conf @@ -31,7 +31,7 @@ server { # 파일 업로드 서비스에 의해 관리되는 정적 파일 서빙 location /objects { - alias /var/denamu_objects/; + alias /var/web05-Denamu/objects/; try_files $uri $uri/ =404; } diff --git a/server/src/common/disk/disk-storage.ts b/server/src/common/disk/disk-storage.ts new file mode 100644 index 00000000..39079c92 --- /dev/null +++ b/server/src/common/disk/disk-storage.ts @@ -0,0 +1,40 @@ +import { diskStorage } from 'multer'; +import { + createDirectoryIfNotExists, + getFileName, + validateAndGetUploadType, +} from './file-utils'; +import { validateFile, FILE_SIZE_LIMITS } from './file-validator'; + +export const createDynamicStorage = () => { + return { + storage: diskStorage({ + destination: (req: any, file, cb) => { + try { + const uploadType = validateAndGetUploadType(req.query.uploadType); + const uploadPath = createDirectoryIfNotExists(uploadType); + cb(null, uploadPath); + } catch (error) { + cb(error, null); + } + }, + filename: (req, file, cb) => { + cb(null, getFileName(file)); + }, + }), + fileFilter: (req: any, file: any, cb: any) => { + try { + const uploadType = validateAndGetUploadType(req.query.uploadType); + validateFile(file, uploadType); + cb(null, true); + } catch (error) { + cb(error, false); + } + }, + limits: { + fileSize: FILE_SIZE_LIMITS.IMAGE, // 기본적으로 이미지 크기 제한 사용 + }, + }; +}; + +export const storage = createDynamicStorage(); diff --git a/server/src/common/disk/diskStorage.ts b/server/src/common/disk/diskStorage.ts deleted file mode 100644 index acb0c9c4..00000000 --- a/server/src/common/disk/diskStorage.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { diskStorage } from 'multer'; -import { createDirectoryIfNotExists, getFileName } from './fileUtils'; -import { - validateFile, - ALLOWED_MIME_TYPES, - FILE_SIZE_LIMITS, - FileUploadType, -} from './fileValidator'; - -export const createDynamicStorage = () => { - return { - storage: diskStorage({ - destination: (req: any, file, cb) => { - const uploadType: FileUploadType = - // TODO: 파일 업로드 타입 추론부 확정나면 변경하기 - req.body?.uploadType || - req.query?.uploadType || - req.uploadType || - 'PROFILE_IMAGE'; - - const uploadPath = createDirectoryIfNotExists(uploadType); - cb(null, uploadPath); - }, - filename: (req, file, cb) => { - cb(null, getFileName(file)); - }, - }), - fileFilter: (req: any, file: any, cb: any) => { - try { - const uploadType: FileUploadType = - req.body?.uploadType || - req.query?.uploadType || - req.uploadType || - 'PROFILE_IMAGE'; - - // uploadType에 따른 허용 타입 결정 - let allowedTypes: string[] = []; - if (uploadType === 'PROFILE_IMAGE') { - allowedTypes = ALLOWED_MIME_TYPES.IMAGE; - } // else if 로 업로드 타입별 허용 MIME TYPE 결정 구문 추가 하기 - - validateFile(file, uploadType); - cb(null, true); - } catch (error) { - cb(error, false); - } - }, - limits: { - fileSize: FILE_SIZE_LIMITS.IMAGE, // 기본적으로 이미지 크기 제한 사용 - }, - }; -}; - -export const storage = createDynamicStorage(); diff --git a/server/src/common/disk/fileUtils.ts b/server/src/common/disk/file-utils.ts similarity index 57% rename from server/src/common/disk/fileUtils.ts rename to server/src/common/disk/file-utils.ts index a2e58812..d9ac2877 100644 --- a/server/src/common/disk/fileUtils.ts +++ b/server/src/common/disk/file-utils.ts @@ -3,10 +3,10 @@ import { ensureDirSync } from 'fs-extra'; import { promises as fs } from 'fs'; import { existsSync } from 'fs'; import { v4 as uuidv4 } from 'uuid'; +import { BadRequestException } from '@nestjs/common'; +import { FileUploadType } from './file-validator'; -// TODO: 테스트 후 기본 경로 제거 하기. -const BASE_UPLOAD_PATH = - process.env.UPLOAD_BASE_PATH || '/var/web05-Denamu/objects'; +const BASE_UPLOAD_PATH = '/var/web05-Denamu/objects'; export const generateFilePath = (originalPath: string): string => { const now = new Date(); @@ -34,3 +34,20 @@ export const deleteFileIfExists = async (filePath: string): Promise => { await fs.unlink(filePath); } }; + +// Interceptor가 Pipes보다 먼저 실행되기에, 타입 유효성 검사 필요함 +export const validateAndGetUploadType = (uploadType: any): FileUploadType => { + if (!uploadType) { + throw new BadRequestException( + `uploadType이 필요합니다. 허용된 타입: ${Object.values(FileUploadType).join(', ')}`, + ); + } + + if (!Object.values(FileUploadType).includes(uploadType)) { + throw new BadRequestException( + `유효하지 않은 파일 업로드 타입입니다. 허용된 타입: ${Object.values(FileUploadType).join(', ')}`, + ); + } + + return uploadType as FileUploadType; +}; diff --git a/server/src/common/disk/fileValidator.ts b/server/src/common/disk/file-validator.ts similarity index 70% rename from server/src/common/disk/fileValidator.ts rename to server/src/common/disk/file-validator.ts index 2b4f874d..b6f55465 100644 --- a/server/src/common/disk/fileValidator.ts +++ b/server/src/common/disk/file-validator.ts @@ -5,11 +5,10 @@ export const ALLOWED_MIME_TYPES = { ALL: [] as string[], }; -export const FILE_UPLOAD_TYPE = { - PROFILE_IMAGE: 'profileImg', -} as const; - -export type FileUploadType = keyof typeof FILE_UPLOAD_TYPE; +export enum FileUploadType { + PROFILE_IMAGE = 'PROFILE_IMAGE', + // 추후 추가될 타입들 명시 +} ALLOWED_MIME_TYPES.ALL = [...ALLOWED_MIME_TYPES.IMAGE]; @@ -19,11 +18,11 @@ export const FILE_SIZE_LIMITS = { DEFAULT: 10 * 1024 * 1024, }; -export const validateFile = (file: any, uploadType: string) => { +export const validateFile = (file: any, uploadType: FileUploadType) => { let allowedTypes: string[] = []; - if (uploadType === 'PROFILE_IMAGE') { + if (uploadType === FileUploadType.PROFILE_IMAGE) { allowedTypes = ALLOWED_MIME_TYPES.IMAGE; - } + } // else if 구문 이나 switch 써서 타입 추가되면 유효성 ALLOWED TYPES 매핑해주기! validateFileType(file, allowedTypes); validateFileSize(file, uploadType); @@ -39,10 +38,10 @@ const validateFileType = (file: any, allowedTypes?: string[]) => { } }; -const validateFileSize = (file: any, uploadType: string) => { +const validateFileSize = (file: any, uploadType: FileUploadType) => { let sizeLimit: number; - if (uploadType === 'PROFILE_IMAGE') { + if (uploadType === FileUploadType.PROFILE_IMAGE) { sizeLimit = FILE_SIZE_LIMITS.IMAGE; } else { sizeLimit = FILE_SIZE_LIMITS.DEFAULT; diff --git a/server/src/file/api-docs/uploadProfileFile.api-docs.ts b/server/src/file/api-docs/uploadProfileFile.api-docs.ts index 88219874..33463bb1 100644 --- a/server/src/file/api-docs/uploadProfileFile.api-docs.ts +++ b/server/src/file/api-docs/uploadProfileFile.api-docs.ts @@ -5,16 +5,25 @@ import { ApiConsumes, ApiOkResponse, ApiOperation, + ApiQuery, ApiUnauthorizedResponse, } from '@nestjs/swagger'; +import { FileUploadType } from '../../common/disk/file-validator'; export function ApiUploadProfileFile() { return applyDecorators( ApiOperation({ - summary: '프로필 이미지 업로드 API', - description: '사용자의 프로필 이미지를 업로드합니다.', + summary: '파일 업로드 API', + description: '사용자의 파일을 업로드합니다.', }), ApiConsumes('multipart/form-data'), + ApiQuery({ + name: 'uploadType', + description: '파일 업로드 타입', + enum: FileUploadType, + example: FileUploadType.PROFILE_IMAGE, + required: true, + }), ApiBody({ description: '업로드할 파일', schema: { @@ -23,7 +32,7 @@ export function ApiUploadProfileFile() { file: { type: 'string', format: 'binary', - description: '업로드할 이미지 파일 (JPG, PNG, GIF 등)', + description: '업로드할 파일 (uploadType별 허용 형식 다름!)', }, }, required: ['file'], @@ -62,7 +71,8 @@ export function ApiUploadProfileFile() { }, url: { type: 'string', - example: '/objects/profile/2024/01/profile-image.jpg', + example: + '/objects/PROFILE_IMAGE/20241215/a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg', description: '파일 접근 URL', }, userId: { @@ -84,10 +94,32 @@ export function ApiUploadProfileFile() { ApiBadRequestResponse({ description: '잘못된 요청', schema: { - properties: { - message: { - type: 'string', - example: '파일이 선택되지 않았습니다.', + examples: { + fileNotSelected: { + summary: '파일 미선택', + value: { + message: '파일이 선택되지 않았습니다.', + }, + }, + invalidUploadType: { + summary: '잘못된 업로드 타입', + value: { + message: + '유효하지 않은 파일 업로드 타입입니다. 허용된 타입: PROFILE_IMAGE', + }, + }, + invalidFileType: { + summary: '지원하지 않는 파일 형식', + value: { + message: + '지원하지 않는 파일 형식입니다. 지원 형식: image/jpeg, image/png, image/gif, image/webp', + }, + }, + fileSizeExceeded: { + summary: '파일 크기 초과', + value: { + message: '파일 크기가 너무 큽니다. 최대 5MB까지 허용됩니다.', + }, }, }, }, diff --git a/server/src/file/controller/file.controller.ts b/server/src/file/controller/file.controller.ts index c7a05ed1..42267ee4 100644 --- a/server/src/file/controller/file.controller.ts +++ b/server/src/file/controller/file.controller.ts @@ -8,6 +8,7 @@ import { Param, UseGuards, BadRequestException, + Query, HttpStatus, HttpCode, } from '@nestjs/common'; @@ -15,11 +16,12 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { FileService } from '../service/file.service'; import { ApiTags } from '@nestjs/swagger'; import { JwtGuard } from '../../common/guard/jwt.guard'; -import { createDynamicStorage } from '../../common/disk/diskStorage'; +import { createDynamicStorage } from '../../common/disk/disk-storage'; import { ApiResponse } from '../../common/response/common.response'; import { ApiUploadProfileFile } from '../api-docs/uploadProfileFile.api-docs'; import { ApiDeleteFile } from '../api-docs/deleteFile.api-docs'; import { FileDeleteRequestDto } from '../dto/request/deleteFile.dto'; +import { FileUploadQueryDto } from '../dto/fileUpload.dto'; @ApiTags('File') @Controller('file') @@ -27,11 +29,15 @@ import { FileDeleteRequestDto } from '../dto/request/deleteFile.dto'; export class FileController { constructor(private readonly fileService: FileService) {} - @Post('profile') + @Post('') @ApiUploadProfileFile() @HttpCode(HttpStatus.CREATED) @UseInterceptors(FileInterceptor('file', createDynamicStorage())) - async upload(@UploadedFile() file: any, @Req() req) { + async upload( + @UploadedFile() file: any, + @Query() query: FileUploadQueryDto, + @Req() req, + ) { if (!file) { throw new BadRequestException('파일이 선택되지 않았습니다.'); } diff --git a/server/src/file/dto/fileUpload.dto.ts b/server/src/file/dto/fileUpload.dto.ts new file mode 100644 index 00000000..0d2132db --- /dev/null +++ b/server/src/file/dto/fileUpload.dto.ts @@ -0,0 +1,16 @@ +import { IsEnum, IsOptional } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; +import { FileUploadType } from '../../common/disk/file-validator'; + +export class FileUploadQueryDto { + @ApiProperty({ + description: '파일 업로드 타입', + enum: FileUploadType, + example: FileUploadType.PROFILE_IMAGE, + required: false, + }) + @IsOptional() + @Transform(({ value }) => value) + uploadType: FileUploadType; +} diff --git a/server/src/file/service/file.service.ts b/server/src/file/service/file.service.ts index b0b16812..40070e62 100644 --- a/server/src/file/service/file.service.ts +++ b/server/src/file/service/file.service.ts @@ -1,19 +1,23 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { File } from '../entity/file.entity'; -import { unlinkSync, existsSync } from 'fs'; +import { unlink, access } from 'fs/promises'; import { FileRepository } from '../repository/file.repository'; import { User } from '../../user/entity/user.entity'; import { FileUploadResponseDto } from '../dto/response/createFile.dto'; +import { WinstonLoggerService } from '../../common/logger/logger.service'; @Injectable() export class FileService { - constructor(private readonly fileRepository: FileRepository) {} + constructor( + private readonly fileRepository: FileRepository, + private readonly logger: WinstonLoggerService, + ) {} async create(file: any, userId: number): Promise { - const { originalName, mimetype, size, path } = file; + const { originalname, mimetype, size, path } = file; const savedFile = await this.fileRepository.save({ - originalName, + originalName: originalname, mimetype, size, path, @@ -25,8 +29,7 @@ export class FileService { } private generateAccessUrl(filePath: string): string { - const baseUploadPath = - process.env.UPLOAD_BASE_PATH || '/var/web05-Denamu/objects'; + const baseUploadPath = '/var/web05-Denamu/objects'; const relativePath = filePath.replace(baseUploadPath, ''); return `/objects${relativePath}`; } @@ -42,8 +45,11 @@ export class FileService { async deleteFile(id: number): Promise { const file = await this.findById(id); - if (existsSync(file.path)) { - unlinkSync(file.path); + try { + await access(file.path); + await unlink(file.path); + } catch (error) { + this.logger.warn(`파일 삭제 실패: ${file.path}`, 'FileService'); } await this.fileRepository.delete(id); @@ -52,4 +58,20 @@ export class FileService { async getFileInfo(id: number): Promise { return this.findById(id); } + + async deleteByPath(path: string): Promise { + const file = await this.fileRepository.findOne({ where: { path } }); + if (file) { + try { + await access(file.path); + await unlink(file.path); + } catch (error) { + this.logger.warn(`파일 삭제 실패: ${file.path}`, 'FileService'); + } + + await this.fileRepository.delete(file.id); + } else { + throw new NotFoundException('파일을 찾을 수 없습니다.'); + } + } } diff --git a/server/src/user/dto/request/update-user.dto.ts b/server/src/user/dto/request/update-user.dto.ts index 2326d1e1..7c9ccefe 100644 --- a/server/src/user/dto/request/update-user.dto.ts +++ b/server/src/user/dto/request/update-user.dto.ts @@ -17,8 +17,8 @@ export class UpdateUserDto { userName?: string; @ApiProperty({ - example: 'uuid', - description: '변경할 프로필 이미지 key', + example: 'https://denamu.site/objects/PROFILE_IMAGE/20250816/uuid.png', + description: '변경할 프로필 이미지 path', required: false, }) @IsOptional() diff --git a/server/src/user/module/user.module.ts b/server/src/user/module/user.module.ts index bb0c2475..24020eec 100644 --- a/server/src/user/module/user.module.ts +++ b/server/src/user/module/user.module.ts @@ -11,9 +11,10 @@ import { AdminModule } from '../../admin/module/admin.module'; import { GoogleOAuthProvider } from '../provider/google.provider'; import { GithubOAuthProvider } from '../provider/github.provider'; import { UserScheduler } from '../scheduler/user.scheduler'; +import { FileModule } from '../../file/module/file.module'; @Module({ - imports: [JwtAuthModule, AdminModule, ScheduleModule.forRoot()], + imports: [JwtAuthModule, AdminModule, FileModule, ScheduleModule.forRoot()], controllers: [UserController, OAuthController], providers: [ UserService, diff --git a/server/src/user/service/user.service.ts b/server/src/user/service/user.service.ts index c5fdc47a..38f5f565 100644 --- a/server/src/user/service/user.service.ts +++ b/server/src/user/service/user.service.ts @@ -18,6 +18,7 @@ import { ConfigService } from '@nestjs/config'; import { cookieConfig } from '../../common/cookie/cookie.config'; import { Payload } from '../../common/guard/jwt.guard'; import { UpdateUserDto } from '../dto/request/update-user.dto'; +import { FileService } from '../../file/service/file.service'; import { CheckEmailDuplicationResponseDto } from '../dto/response/checkEmailDuplication.dto'; @Injectable() @@ -28,6 +29,7 @@ export class UserService { private readonly emailService: EmailService, private readonly jwtService: JwtService, private readonly configService: ConfigService, + private readonly fileService: FileService, ) {} async getUser(userId: number) { @@ -168,13 +170,19 @@ export class UserService { ): Promise { const user = await this.getUser(userId); - user.userName = updateData.userName ? updateData.userName : user.userName; - user.profileImage = updateData.profileImage - ? updateData.profileImage - : user.profileImage; - user.introduction = updateData.introduction - ? updateData.introduction - : user.introduction; + if (updateData.userName !== undefined) { + user.userName = updateData.userName; + } + if ( + updateData.profileImage !== undefined && + user.profileImage !== updateData.profileImage + ) { + await this.fileService.deleteByPath(user.profileImage); + user.profileImage = updateData.profileImage; + } + if (updateData.introduction !== undefined) { + user.introduction = updateData.introduction; + } await this.userRepository.save(user); } diff --git a/server/test/user/e2e/update-profile.e2e-spec.ts b/server/test/user/e2e/update-profile.e2e-spec.ts index 39312990..455935f1 100644 --- a/server/test/user/e2e/update-profile.e2e-spec.ts +++ b/server/test/user/e2e/update-profile.e2e-spec.ts @@ -4,18 +4,20 @@ import { UserService } from '../../../src/user/service/user.service'; import { UserRepository } from '../../../src/user/repository/user.repository'; import { UserFixture } from '../../fixture/user.fixture'; import { User } from '../../../src/user/entity/user.entity'; -import { UpdateUserDto } from '../../../src/user/dto/request/update-user.dto'; +import { FileService } from '../../../src/file/service/file.service'; describe('PATCH /api/user/profile E2E Test', () => { let app: INestApplication; let userService: UserService; let userRepository: UserRepository; + let fileService: FileService; let testUser: User; const testUpdateData = { complete: { userName: '변경된이름', - profileImage: 'new-profile-uuid', + profileImage: + 'https://denamu.site/objects/PROFILE_IMAGE/20000902/uuid.png', introduction: '변경된 소개글입니다.', }, partial: { @@ -27,16 +29,24 @@ describe('PATCH /api/user/profile E2E Test', () => { app = global.testApp; userService = app.get(UserService); userRepository = app.get(UserRepository); + fileService = app.get(FileService); + + jest.spyOn(fileService, 'deleteByPath').mockResolvedValue(undefined); testUser = await userRepository.save( await UserFixture.createUserCryptFixture({ userName: '기존이름', - profileImage: 'old-profile-uuid', + profileImage: + 'https://denamu.site/objects/PROFILE_IMAGE/20000902/uuid_old.png', introduction: '기존 소개글입니다.', }), ); }); + afterAll(async () => { + jest.restoreAllMocks(); + }); + it('로그인한 사용자가 프로필 정보를 성공적으로 수정한다.', async () => { // given const accessToken = userService.createToken(