Skip to content

Commit 39b0fbb

Browse files
authored
Merge pull request #435 from boostcampwm-2024/feat/file-upload
✨ feat: 파일업로드 파이프라인 및 API 구현
2 parents 90446d6 + ef70b84 commit 39b0fbb

File tree

16 files changed

+473
-0
lines changed

16 files changed

+473
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ certificate.crt
3333
!.vscode/extensions.json
3434
!.vscode/*.code-snippets
3535

36+
### static files
37+
**/objects
38+
3639
# Local History for Visual Studio Code
3740
.history/
3841

docker-compose/docker-compose.prod.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ services:
2121
condition: service_healthy
2222
volumes:
2323
- ../server/logs:/var/web05-Denamu/server/logs
24+
- /var/web05-Denamu/objects:/var/web05-Denamu/objects
2425
environment:
2526
NODE_ENV: "PROD"
2627
TZ: "Asia/Seoul"

nginx.conf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ server {
2929
try_files $uri $uri/ =404;
3030
}
3131

32+
# 파일 업로드 서비스에 의해 관리되는 정적 파일 서빙
33+
location /objects {
34+
alias /var/denamu_objects/;
35+
try_files $uri $uri/ =404;
36+
}
37+
3238
# API 요청을 NestJS로 프록시
3339
location /api {
3440
proxy_pass http://127.0.0.1:8080;

nginx/nginx.conf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ http {
8787
try_files $uri $uri/ =404;
8888
}
8989

90+
# 업로드된 파일 서빙
91+
location /objects {
92+
alias /var/web05-Denamu/objects/;
93+
try_files $uri $uri/ =404;
94+
}
95+
9096
# API 요청을 NestJS로 프록시
9197
location /api {
9298
proxy_pass http://app:8080;

package-lock.json

Lines changed: 97 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"devDependencies": {
33
"@commitlint/cli": "^19.5.0",
4+
"@types/multer": "^1.4.13",
45
"artillery": "^2.0.21",
56
"commitizen": "^4.3.1",
67
"cz-cli": "^1.0.0",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class CreateFile1754122785239 implements MigrationInterface {
4+
name = 'CreateFile1754122785239';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`CREATE TABLE \`file\` (\`id\` int NOT NULL AUTO_INCREMENT, \`original_name\` varchar(255) NOT NULL, \`mimetype\` varchar(255) NOT NULL, \`path\` varchar(255) NOT NULL, \`size\` int NOT NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`user_id\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
9+
);
10+
await queryRunner.query(
11+
`ALTER TABLE \`file\` ADD CONSTRAINT \`FK_516f1cf15166fd07b732b4b6ab0\` FOREIGN KEY (\`user_id\`) REFERENCES \`user\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`,
12+
);
13+
}
14+
15+
public async down(queryRunner: QueryRunner): Promise<void> {
16+
await queryRunner.query(
17+
`ALTER TABLE \`file\` DROP FOREIGN KEY \`FK_516f1cf15166fd07b732b4b6ab0\``,
18+
);
19+
await queryRunner.query(`DROP TABLE \`file\``);
20+
}
21+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { diskStorage } from 'multer';
2+
import { createDirectoryIfNotExists, getFileName } from './fileUtils';
3+
import {
4+
validateFile,
5+
ALLOWED_MIME_TYPES,
6+
FILE_SIZE_LIMITS,
7+
FileUploadType,
8+
} from './fileValidator';
9+
10+
export const createDynamicStorage = () => {
11+
return {
12+
storage: diskStorage({
13+
destination: (req: any, file, cb) => {
14+
const uploadType: FileUploadType =
15+
// TODO: 파일 업로드 타입 추론부 확정나면 변경하기
16+
req.body?.uploadType ||
17+
req.query?.uploadType ||
18+
req.uploadType ||
19+
'PROFILE_IMAGE';
20+
21+
const uploadPath = createDirectoryIfNotExists(uploadType);
22+
cb(null, uploadPath);
23+
},
24+
filename: (req, file, cb) => {
25+
cb(null, getFileName(file));
26+
},
27+
}),
28+
fileFilter: (req: any, file: any, cb: any) => {
29+
try {
30+
const uploadType: FileUploadType =
31+
req.body?.uploadType ||
32+
req.query?.uploadType ||
33+
req.uploadType ||
34+
'PROFILE_IMAGE';
35+
36+
// uploadType에 따른 허용 타입 결정
37+
let allowedTypes: string[] = [];
38+
if (uploadType === 'PROFILE_IMAGE') {
39+
allowedTypes = ALLOWED_MIME_TYPES.IMAGE;
40+
} // else if 로 업로드 타입별 허용 MIME TYPE 결정 구문 추가 하기
41+
42+
validateFile(file, uploadType);
43+
cb(null, true);
44+
} catch (error) {
45+
cb(error, false);
46+
}
47+
},
48+
limits: {
49+
fileSize: FILE_SIZE_LIMITS.IMAGE, // 기본적으로 이미지 크기 제한 사용
50+
},
51+
};
52+
};
53+
54+
export const storage = createDynamicStorage();
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { join } from 'path';
2+
import { ensureDirSync } from 'fs-extra';
3+
import { promises as fs } from 'fs';
4+
import { existsSync } from 'fs';
5+
import { v4 as uuidv4 } from 'uuid';
6+
7+
// TODO: 테스트 후 기본 경로 제거 하기.
8+
const BASE_UPLOAD_PATH =
9+
process.env.UPLOAD_BASE_PATH || '/var/web05-Denamu/objects';
10+
11+
export const generateFilePath = (originalPath: string): string => {
12+
const now = new Date();
13+
const folder = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`;
14+
return join(originalPath, folder);
15+
};
16+
17+
export const getFileName = (file: any): string => {
18+
const ext = file.originalname.split('.').pop()?.toLowerCase() || '';
19+
return `${uuidv4()}.${ext}`;
20+
};
21+
22+
export const createDirectoryIfNotExists = (uploadType: string): string => {
23+
const now = new Date();
24+
const dateFolder = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
25+
26+
const uploadPath = join(BASE_UPLOAD_PATH, uploadType, dateFolder);
27+
28+
ensureDirSync(uploadPath);
29+
return uploadPath;
30+
};
31+
32+
export const deleteFileIfExists = async (filePath: string): Promise<void> => {
33+
if (existsSync(filePath)) {
34+
await fs.unlink(filePath);
35+
}
36+
};
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { BadRequestException } from '@nestjs/common';
2+
3+
export const ALLOWED_MIME_TYPES = {
4+
IMAGE: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
5+
ALL: [] as string[],
6+
};
7+
8+
export const FILE_UPLOAD_TYPE = {
9+
PROFILE_IMAGE: 'profileImg',
10+
} as const;
11+
12+
export type FileUploadType = keyof typeof FILE_UPLOAD_TYPE;
13+
14+
ALLOWED_MIME_TYPES.ALL = [...ALLOWED_MIME_TYPES.IMAGE];
15+
16+
export const FILE_SIZE_LIMITS = {
17+
// MB 단위
18+
IMAGE: 5 * 1024 * 1024,
19+
DEFAULT: 10 * 1024 * 1024,
20+
};
21+
22+
export const validateFile = (file: any, uploadType: string) => {
23+
let allowedTypes: string[] = [];
24+
if (uploadType === 'PROFILE_IMAGE') {
25+
allowedTypes = ALLOWED_MIME_TYPES.IMAGE;
26+
}
27+
28+
validateFileType(file, allowedTypes);
29+
validateFileSize(file, uploadType);
30+
};
31+
32+
const validateFileType = (file: any, allowedTypes?: string[]) => {
33+
const types = allowedTypes || [];
34+
35+
if (!types.includes(file.mimetype)) {
36+
throw new BadRequestException(
37+
`지원하지 않는 파일 형식입니다. 지원 형식: ${types.join(', ')}`,
38+
);
39+
}
40+
};
41+
42+
const validateFileSize = (file: any, uploadType: string) => {
43+
let sizeLimit: number;
44+
45+
if (uploadType === 'PROFILE_IMAGE') {
46+
sizeLimit = FILE_SIZE_LIMITS.IMAGE;
47+
} else {
48+
sizeLimit = FILE_SIZE_LIMITS.DEFAULT;
49+
}
50+
51+
if (file.size > sizeLimit) {
52+
throw new BadRequestException(
53+
`파일 크기가 너무 큽니다. 최대 ${Math.round(sizeLimit / 1024 / 1024)}MB까지 허용됩니다.`,
54+
);
55+
}
56+
};

0 commit comments

Comments
 (0)