Skip to content

Commit 433fd89

Browse files
authored
Merge pull request #82 from Zy0ung/file-upload
feat: 파일 업로드 Presigned URL 생성 API 작업
2 parents 89c8394 + a7946a3 commit 433fd89

File tree

7 files changed

+232
-1
lines changed

7 files changed

+232
-1
lines changed

build.gradle

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ dependencies {
8181

8282
// Swagger
8383
implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.6.0'
84+
85+
// Minio
86+
implementation 'io.minio:minio:8.5.11'
87+
88+
// S3
89+
implementation(platform("software.amazon.awssdk:bom:2.21.1"))
90+
implementation("software.amazon.awssdk:s3")
91+
8492
}
8593

8694
tasks.named('test') {

docker-compose.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,20 @@ services:
5656
KAFKA_HEAP_OPTS: "-Xms256M -Xmx512M" # JVM 힙 메모리 최소/최대 설정
5757
depends_on:
5858
- zookeeper
59-
restart: always
59+
restart: always
60+
61+
minio:
62+
image: minio/minio
63+
container_name: minio
64+
environment:
65+
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
66+
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
67+
ports:
68+
- "9000:9000" # 로컬에서 9000 포트로 접근 가능
69+
volumes:
70+
- minio_data:/data # 데이터 저장소
71+
command: server /data
72+
73+
volumes:
74+
minio_data:
75+
driver: local

src/main/java/org/myteam/server/global/exception/ErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public enum ErrorCode {
2020
INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "Invalid parameter value"),
2121
EMPTY_COOKIE(HttpStatus.BAD_REQUEST, "Cookie value is empty"),
2222
INVALID_TYPE(HttpStatus.BAD_REQUEST, "Invalid type provided"),
23+
INVALID_MIME_TYPE(HttpStatus.BAD_REQUEST, "Invalid mime type provided"),
2324

2425
// 401 Unauthorized,
2526
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Unauthorized"),
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package org.myteam.server.upload.config;
2+
3+
import java.net.URI;
4+
import org.springframework.beans.factory.annotation.Value;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
8+
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
9+
import software.amazon.awssdk.regions.Region;
10+
import software.amazon.awssdk.services.s3.S3Client;
11+
import software.amazon.awssdk.services.s3.S3Configuration;
12+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
13+
14+
/**
15+
* 로컬 실행용 S3 Config
16+
*/
17+
@Configuration
18+
public class S3ConfigLocal {
19+
20+
@Value("${minio.url}")
21+
private String minioUrl;
22+
23+
@Value("${minio.access-key}")
24+
private String minioAccessKey;
25+
26+
@Value("${minio.secret-key}")
27+
private String minioSecretKey;
28+
29+
@Value("${minio.region}")
30+
private String region;
31+
32+
@Bean
33+
public S3Client minioClient() {
34+
return S3Client.builder()
35+
.endpointOverride(URI.create(minioUrl))
36+
.region(Region.of(region))
37+
.credentialsProvider(StaticCredentialsProvider
38+
.create(AwsBasicCredentials
39+
.create(minioAccessKey, minioSecretKey)))
40+
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
41+
.build();
42+
}
43+
44+
@Bean
45+
public S3Presigner s3Presigner() {
46+
return S3Presigner.builder()
47+
.endpointOverride(URI.create(minioUrl))
48+
.region(Region.of(region))
49+
.credentialsProvider(StaticCredentialsProvider
50+
.create(AwsBasicCredentials
51+
.create(minioAccessKey, minioSecretKey)))
52+
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
53+
.build();
54+
}
55+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package org.myteam.server.upload.controller;
2+
3+
import static org.myteam.server.global.web.response.ResponseStatus.SUCCESS;
4+
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.myteam.server.global.web.response.ResponseDto;
8+
import org.myteam.server.upload.service.S3PresignedUrlService;
9+
import org.springframework.http.ResponseEntity;
10+
import org.springframework.web.bind.annotation.GetMapping;
11+
import org.springframework.web.bind.annotation.RequestParam;
12+
import org.springframework.web.bind.annotation.RestController;
13+
14+
@RestController
15+
@RequiredArgsConstructor
16+
@Slf4j
17+
public class S3FileUploadController {
18+
19+
private final S3PresignedUrlService s3PresignedUrlService;
20+
21+
/**
22+
* Presigned URL 생성
23+
*
24+
* @param fileName 업로드할 파일의 이름
25+
* @param contentType 파일의 MIME 타입 (예: image/png)
26+
* @return Presigned URL
27+
*/
28+
@GetMapping("/upload")
29+
public ResponseEntity<ResponseDto<String>> generatePresignedUrl(
30+
@RequestParam("contentType") String contentType,
31+
@RequestParam("fileName") String fileName) {
32+
33+
// Presigned URL 생성
34+
String response = s3PresignedUrlService.generatePresignedUrl(fileName, contentType);
35+
36+
return ResponseEntity.ok(new ResponseDto<>(SUCCESS.name(), "Presigned URL 생성 성공", response));
37+
}
38+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package org.myteam.server.upload.domain;
2+
3+
public enum ContentType {
4+
IMAGE_PNG("image/png"),
5+
IMAGE_JPEG("image/jpeg"),
6+
IMAGE_GIF("image/gif"),
7+
IMAGE_WEBP("image/webp"),
8+
IMAGE_HEIC("image/heic"),
9+
VIDEO_MP4("video/mp4"),
10+
VIDEO_MOV("video/quicktime"),
11+
VIDEO_WEBM("video/webm");
12+
13+
private final String value;
14+
15+
ContentType(String value) {
16+
this.value = value;
17+
}
18+
19+
// 값 반환 메서드
20+
public String getValue() {
21+
return value;
22+
}
23+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package org.myteam.server.upload.service;
2+
3+
import java.time.Duration;
4+
import java.util.UUID;
5+
import lombok.RequiredArgsConstructor;
6+
import org.apache.commons.io.FilenameUtils;
7+
import org.myteam.server.global.exception.ErrorCode;
8+
import org.myteam.server.global.exception.PlayHiveException;
9+
import org.myteam.server.upload.domain.ContentType;
10+
import org.springframework.beans.factory.annotation.Value;
11+
import org.springframework.stereotype.Service;
12+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
13+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
14+
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
public class S3PresignedUrlService {
19+
20+
@Value("${minio.bucket}")
21+
private String bucket;
22+
private final S3Presigner s3Presigner;
23+
24+
/**
25+
* Presigned URL 생성
26+
*
27+
* @param fileName 업로드할 파일의 이름
28+
* @param contentType 파일의 MIME 타입
29+
* @return 생성된 presigned URL
30+
*/
31+
public String generatePresignedUrl(String fileName, String contentType) {
32+
33+
// 미디어 타입 & 파일명 검사
34+
verifyMimeType(contentType, fileName);
35+
36+
// 폴더 (image or video) 설정
37+
String feature = contentType.split("/")[0].toLowerCase().equals("image") ? "image" : "video";
38+
39+
// 파일명은 고유하도록 UUID 설정
40+
String uniqueFileName = feature + "/" + UUID.randomUUID() + "-" + fileName;
41+
42+
// Presigned URL 요청 생성
43+
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
44+
.bucket(bucket)
45+
.key(uniqueFileName) // 파일 이름을 지정
46+
.contentType(contentType) // 파일 MIME 타입
47+
.build();
48+
49+
// URL 만료 시간 설정
50+
Duration expiration = Duration.ofMinutes(10); // 10분
51+
52+
// PresignedPutObjectRequest 생성 (Presigner를 사용하여 Presigned URL을 생성)
53+
PresignedPutObjectRequest presignedPutObjectRequest = s3Presigner.presignPutObject(
54+
presignRequest -> presignRequest.putObjectRequest(putObjectRequest)
55+
.signatureDuration(expiration)
56+
);
57+
58+
// URL 반환
59+
return presignedPutObjectRequest.url().toString();
60+
}
61+
62+
/**
63+
* MIME 타입 & 확장자 검사
64+
*/
65+
private void verifyMimeType(String contentType, String fileName) {
66+
67+
// 파일 확장자
68+
String fileExtension = FilenameUtils.getExtension(fileName).toLowerCase();
69+
70+
if (!isValidMimeType(contentType, fileExtension)) {
71+
throw new PlayHiveException(ErrorCode.INVALID_MIME_TYPE);
72+
}
73+
74+
}
75+
76+
private boolean isValidMimeType(String contentType, String fileExtension) {
77+
for (ContentType type : ContentType.values()) {
78+
if (type.getValue().equalsIgnoreCase(contentType)) {
79+
// MIME 타입에서 / 뒤에 있는 확장자 부분을 추출
80+
String mimeExtension = contentType.split("/")[1].toLowerCase();
81+
82+
// 파일 확장자와 비교
83+
if (mimeExtension.equalsIgnoreCase(fileExtension)) {
84+
return true;
85+
}
86+
}
87+
}
88+
return false;
89+
}
90+
}

0 commit comments

Comments
 (0)