Skip to content
Merged
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
8 changes: 8 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ dependencies {

// Swagger
implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.6.0'

// Minio
implementation 'io.minio:minio:8.5.11'

// S3
implementation(platform("software.amazon.awssdk:bom:2.21.1"))
implementation("software.amazon.awssdk:s3")

}

tasks.named('test') {
Expand Down
18 changes: 17 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,20 @@ services:
KAFKA_HEAP_OPTS: "-Xms256M -Xmx512M" # JVM 힙 메모리 최소/최대 설정
depends_on:
- zookeeper
restart: always
restart: always

minio:
image: minio/minio
container_name: minio
environment:
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
ports:
- "9000:9000" # 로컬에서 9000 포트로 접근 가능
volumes:
- minio_data:/data # 데이터 저장소
command: server /data

volumes:
minio_data:
driver: local
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public enum ErrorCode {
INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "Invalid parameter value"),
EMPTY_COOKIE(HttpStatus.BAD_REQUEST, "Cookie value is empty"),
INVALID_TYPE(HttpStatus.BAD_REQUEST, "Invalid type provided"),
INVALID_MIME_TYPE(HttpStatus.BAD_REQUEST, "Invalid mime type provided"),

// 401 Unauthorized,
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Unauthorized"),
Expand Down
55 changes: 55 additions & 0 deletions src/main/java/org/myteam/server/upload/config/S3ConfigLocal.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package org.myteam.server.upload.config;

import java.net.URI;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;

/**
* 로컬 실행용 S3 Config
*/
@Configuration
public class S3ConfigLocal {

@Value("${minio.url}")
private String minioUrl;

@Value("${minio.access-key}")
private String minioAccessKey;

@Value("${minio.secret-key}")
private String minioSecretKey;

@Value("${minio.region}")
private String region;

@Bean
public S3Client minioClient() {
return S3Client.builder()
.endpointOverride(URI.create(minioUrl))
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider
.create(AwsBasicCredentials
.create(minioAccessKey, minioSecretKey)))
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
.build();
}

@Bean
public S3Presigner s3Presigner() {
return S3Presigner.builder()
.endpointOverride(URI.create(minioUrl))
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider
.create(AwsBasicCredentials
.create(minioAccessKey, minioSecretKey)))
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.myteam.server.upload.controller;

import static org.myteam.server.global.web.response.ResponseStatus.SUCCESS;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.myteam.server.global.web.response.ResponseDto;
import org.myteam.server.upload.service.S3PresignedUrlService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@Slf4j
public class S3FileUploadController {

private final S3PresignedUrlService s3PresignedUrlService;

/**
* Presigned URL 생성
*
* @param fileName 업로드할 파일의 이름
* @param contentType 파일의 MIME 타입 (예: image/png)
* @return Presigned URL
*/
@GetMapping("/upload")
public ResponseEntity<ResponseDto<String>> generatePresignedUrl(
@RequestParam("contentType") String contentType,
@RequestParam("fileName") String fileName) {

// Presigned URL 생성
String response = s3PresignedUrlService.generatePresignedUrl(fileName, contentType);

return ResponseEntity.ok(new ResponseDto<>(SUCCESS.name(), "Presigned URL 생성 성공", response));
}
}
23 changes: 23 additions & 0 deletions src/main/java/org/myteam/server/upload/domain/ContentType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.myteam.server.upload.domain;

public enum ContentType {
IMAGE_PNG("image/png"),
IMAGE_JPEG("image/jpeg"),
IMAGE_GIF("image/gif"),
IMAGE_WEBP("image/webp"),
IMAGE_HEIC("image/heic"),
VIDEO_MP4("video/mp4"),
VIDEO_MOV("video/quicktime"),
VIDEO_WEBM("video/webm");

private final String value;

ContentType(String value) {
this.value = value;
}

// 값 반환 메서드
public String getValue() {
return value;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package org.myteam.server.upload.service;

import java.time.Duration;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.commons.io.FilenameUtils;
import org.myteam.server.global.exception.ErrorCode;
import org.myteam.server.global.exception.PlayHiveException;
import org.myteam.server.upload.domain.ContentType;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;

@Service
@RequiredArgsConstructor
public class S3PresignedUrlService {

@Value("${minio.bucket}")
private String bucket;
private final S3Presigner s3Presigner;

/**
* Presigned URL 생성
*
* @param fileName 업로드할 파일의 이름
* @param contentType 파일의 MIME 타입
* @return 생성된 presigned URL
*/
public String generatePresignedUrl(String fileName, String contentType) {

// 미디어 타입 & 파일명 검사
verifyMimeType(contentType, fileName);

// 폴더 (image or video) 설정
String feature = contentType.split("/")[0].toLowerCase().equals("image") ? "image" : "video";

// 파일명은 고유하도록 UUID 설정
String uniqueFileName = feature + "/" + UUID.randomUUID() + "-" + fileName;

// Presigned URL 요청 생성
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(uniqueFileName) // 파일 이름을 지정
.contentType(contentType) // 파일 MIME 타입
.build();

// URL 만료 시간 설정
Duration expiration = Duration.ofMinutes(10); // 10분

// PresignedPutObjectRequest 생성 (Presigner를 사용하여 Presigned URL을 생성)
PresignedPutObjectRequest presignedPutObjectRequest = s3Presigner.presignPutObject(
presignRequest -> presignRequest.putObjectRequest(putObjectRequest)
.signatureDuration(expiration)
);

// URL 반환
return presignedPutObjectRequest.url().toString();
}

/**
* MIME 타입 & 확장자 검사
*/
private void verifyMimeType(String contentType, String fileName) {

// 파일 확장자
String fileExtension = FilenameUtils.getExtension(fileName).toLowerCase();

if (!isValidMimeType(contentType, fileExtension)) {
throw new PlayHiveException(ErrorCode.INVALID_MIME_TYPE);
}

}

private boolean isValidMimeType(String contentType, String fileExtension) {
for (ContentType type : ContentType.values()) {
if (type.getValue().equalsIgnoreCase(contentType)) {
// MIME 타입에서 / 뒤에 있는 확장자 부분을 추출
String mimeExtension = contentType.split("/")[1].toLowerCase();

// 파일 확장자와 비교
if (mimeExtension.equalsIgnoreCase(fileExtension)) {
return true;
}
}
}
return false;
}
}