Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ server {

# 파일 업로드 서비스에 의해 관리되는 정적 파일 서빙
location /objects {
alias /var/denamu_objects/;
alias /var/web05-Denamu/objects/;
try_files $uri $uri/ =404;
}

Expand Down
40 changes: 40 additions & 0 deletions server/src/common/disk/disk-storage.ts
Original file line number Diff line number Diff line change
@@ -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();
54 changes: 0 additions & 54 deletions server/src/common/disk/diskStorage.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -34,3 +34,20 @@ export const deleteFileIfExists = async (filePath: string): Promise<void> => {
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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand All @@ -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);
Expand All @@ -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;
Expand Down
48 changes: 40 additions & 8 deletions server/src/file/api-docs/uploadProfileFile.api-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -23,7 +32,7 @@ export function ApiUploadProfileFile() {
file: {
type: 'string',
format: 'binary',
description: '업로드할 이미지 파일 (JPG, PNG, GIF 등)',
description: '업로드할 파일 (uploadType별 허용 형식 다름!)',
},
},
required: ['file'],
Expand Down Expand Up @@ -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: {
Expand All @@ -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까지 허용됩니다.',
},
},
},
},
Expand Down
12 changes: 9 additions & 3 deletions server/src/file/controller/file.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,36 @@ import {
Param,
UseGuards,
BadRequestException,
Query,
HttpStatus,
HttpCode,
} from '@nestjs/common';
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')
@UseGuards(JwtGuard)
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('파일이 선택되지 않았습니다.');
}
Expand Down
16 changes: 16 additions & 0 deletions server/src/file/dto/fileUpload.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
38 changes: 30 additions & 8 deletions server/src/file/service/file.service.ts
Original file line number Diff line number Diff line change
@@ -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<FileUploadResponseDto> {
const { originalName, mimetype, size, path } = file;
const { originalname, mimetype, size, path } = file;

const savedFile = await this.fileRepository.save({
originalName,
originalName: originalname,
mimetype,
size,
path,
Expand All @@ -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}`;
}
Expand All @@ -42,8 +45,11 @@ export class FileService {
async deleteFile(id: number): Promise<void> {
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);
Expand All @@ -52,4 +58,20 @@ export class FileService {
async getFileInfo(id: number): Promise<File> {
return this.findById(id);
}

async deleteByPath(path: string): Promise<void> {
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('파일을 찾을 수 없습니다.');
}
}
}
Loading