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
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ class AdminImageUploadControllerDocsTest {
.header("Authorization", "Bearer test_access_token")
.param("rootPath", "admin/banner")
.param("uploadSize", "2")
.param("contentType", "image/jpeg")
)
.hasStatusOk()
.apply(
Expand All @@ -92,7 +93,8 @@ class AdminImageUploadControllerDocsTest {
),
queryParameters(
parameterWithName("rootPath").description("업로드 경로 (예: admin/banner, admin/alcohol)"),
parameterWithName("uploadSize").description("발급할 URL 개수")
parameterWithName("uploadSize").description("발급할 URL 개수"),
parameterWithName("contentType").description("업로드 파일의 Content-Type (image/jpeg, image/png, image/webp, video/mp4), 업로드(PUT) 요청 시 동일한 Content-Type 헤더 필요").optional()
),
responseFields(
fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() {
return try {
connection.doOutput = true
connection.requestMethod = "PUT"
connection.setRequestProperty("Content-Type", "application/octet-stream")
connection.setRequestProperty("Content-Type", "image/jpeg")

OutputStreamWriter(connection.outputStream).use { writer ->
writer.write(content)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,36 @@
package app.bottlenote.common.file;

import static app.bottlenote.common.file.exception.FileExceptionCode.EXPIRY_TIME_RANGE_INVALID;
import static app.bottlenote.common.file.exception.FileExceptionCode.UNSUPPORTED_CONTENT_TYPE;
import static java.time.format.DateTimeFormatter.ofPattern;

import app.bottlenote.common.file.exception.FileException;
import java.time.LocalDate;
import java.util.Calendar;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;

public interface PreSignUrlProvider {

String EXTENSION = "jpg";
Map<String, String> ALLOWED_CONTENT_TYPES =
Map.of(
"image/jpeg", "jpg",
"image/png", "png",
"image/webp", "webp",
"video/mp4", "mp4");

String PATH_DELIMITER = "/";
String KEY_DELIMITER = "-";

/**
* PreSignUrl을 생성한다.
*
* @param imageKey the image key
* @param contentType 업로드할 파일의 Content-Type
* @return the string
*/
String generatePreSignUrl(String imageKey);
String generatePreSignUrl(String imageKey, String contentType);

/**
* ViewUrl을 생성한다. cloud front url 에 s3 key를 조합해 반환한다. 실제 오브젝트를 조회하기 위해 사용된다.
Expand All @@ -33,20 +42,33 @@ public interface PreSignUrlProvider {
String generateViewUrl(String cloudFrontUrl, String imageKey);

/**
* 루트 경로를 포함한 이미지 키를 생성한다. 확장자의 경우 .jpg로 고정한다.
* 루트 경로를 포함한 오브젝트 키를 생성한다. contentType에 따라 확장자를 결정한다.
*
* @param rootPath 저장할 루트 경로
* @return 생성된 이미지 키
* @param index 업로드 순번
* @param contentType 업로드할 파일의 Content-Type
* @return 생성된 오브젝트 키
*/
default String getImageKey(String rootPath, Long index) {
default String getImageKey(String rootPath, Long index, String contentType) {
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MIME media type은 규격상 대소문자/공백에 대해 관대하게 처리되는 경우가 많습니다(예: "Image/JPEG", "image/jpeg; charset=utf-8", 앞뒤 공백). 현재는 Map key와 완전 일치만 허용해서 유효한 입력도 400으로 거절될 수 있으니, lookup 전에 trim() + 소문자 정규화 및 필요 시 파라미터(예: ; charset=) 제거 또는 Spring MediaType 파싱 기반 비교로 허용 여부를 판단하도록 개선하는 것을 권장합니다.

Copilot uses AI. Check for mistakes.
if (rootPath.startsWith(PATH_DELIMITER)) {
rootPath = rootPath.substring(1);
}
if (rootPath.endsWith(PATH_DELIMITER)) {
rootPath = rootPath.substring(0, rootPath.length() - 1);
}

String normalized = contentType.strip().toLowerCase();
int semicolon = normalized.indexOf(';');
if (semicolon > 0) {
normalized = normalized.substring(0, semicolon).strip();
}
String extension = ALLOWED_CONTENT_TYPES.get(normalized);
if (extension == null) {
throw new FileException(UNSUPPORTED_CONTENT_TYPE);
}

String uploadAt = LocalDate.now().format(ofPattern("yyyyMMdd"));
String imageId = index + KEY_DELIMITER + UUID.randomUUID() + "." + EXTENSION;
String imageId = index + KEY_DELIMITER + UUID.randomUUID() + "." + extension;

return rootPath + PATH_DELIMITER + uploadAt + PATH_DELIMITER + imageId;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package app.bottlenote.common.file.dto.request;

public record ImageUploadRequest(String rootPath, Long uploadSize) {
public record ImageUploadRequest(String rootPath, Long uploadSize, String contentType) {
public ImageUploadRequest {
uploadSize = uploadSize == null ? 1 : uploadSize;
contentType = contentType == null ? "image/jpeg" : contentType;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
import org.springframework.http.HttpStatus;

public enum FileExceptionCode implements ExceptionCode {
EXPIRY_TIME_RANGE_INVALID(HttpStatus.BAD_REQUEST, "만료 기간의 범위가 적절하지 않습니다.( 최소 1분 ,최대 10분) ");
EXPIRY_TIME_RANGE_INVALID(HttpStatus.BAD_REQUEST, "만료 기간의 범위가 적절하지 않습니다.( 최소 1분 ,최대 10분) "),
UNSUPPORTED_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 Content-Type입니다.");
Comment on lines +7 to +8
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EXPIRY_TIME_RANGE_INVALID 메시지에 불필요한 공백(예: "( 최소 1분 ,최대 10분) ")이 포함되어 가독성이 떨어집니다. 또한 UNSUPPORTED_CONTENT_TYPE는 어떤 값이 거절되었는지/무엇이 허용되는지 정보가 없어 디버깅이 어려우니, 가능하다면 거절된 contentType과 허용 목록을 메시지/에러 상세(예: exception detail)로 포함시키는 개선을 권장합니다.

Suggested change
EXPIRY_TIME_RANGE_INVALID(HttpStatus.BAD_REQUEST, "만료 기간의 범위가 적절하지 않습니다.( 최소 1분 ,최대 10분) "),
UNSUPPORTED_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 Content-Type입니다.");
EXPIRY_TIME_RANGE_INVALID(HttpStatus.BAD_REQUEST, "만료 기간의 범위가 적절하지 않습니다.(최소 1분, 최대 10분)"),
UNSUPPORTED_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 Content-Type입니다. 요청하신 Content-Type과 허용되는 목록을 확인해주세요.");

Copilot uses AI. Check for mistakes.

private final HttpStatus httpStatus;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import app.bottlenote.global.security.SecurityContextUtil;
import com.amazonaws.HttpMethod;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
Expand Down Expand Up @@ -64,11 +65,12 @@ public ImageUploadResponse getPreSignUrlForAdmin(Long adminId, ImageUploadReques
private List<ImageUploadItem> generatePreSignUrls(ImageUploadRequest request) {
String rootPath = request.rootPath();
Long uploadSize = request.uploadSize();
String contentType = request.contentType();
List<ImageUploadItem> keys = new ArrayList<>();

for (long index = 1; index <= uploadSize; index++) {
String imageKey = getImageKey(rootPath, index);
String preSignUrl = generatePreSignUrl(imageKey);
String imageKey = getImageKey(rootPath, index, contentType);
String preSignUrl = generatePreSignUrl(imageKey, contentType);
String viewUrl = generateViewUrl(cloudFrontUrl, imageKey);
keys.add(
ImageUploadItem.builder().order(index).viewUrl(viewUrl).uploadUrl(preSignUrl).build());
Expand Down Expand Up @@ -97,11 +99,14 @@ public String generateViewUrl(String cloudFrontUrl, String imageKey) {
}

@Override
public String generatePreSignUrl(String imageKey) {
public String generatePreSignUrl(String imageKey, String contentType) {
Calendar uploadExpiryTime = getUploadExpiryTime(EXPIRY_TIME);
return amazonS3
.generatePresignedUrl(imageBucketName, imageKey, uploadExpiryTime.getTime(), HttpMethod.PUT)
.toString();
GeneratePresignedUrlRequest request =
new GeneratePresignedUrlRequest(imageBucketName, imageKey)
.withMethod(HttpMethod.PUT)
.withExpiration(uploadExpiryTime.getTime())
.withContentType(contentType);
return amazonS3.generatePresignedUrl(request).toString();
}

private void saveImageUploadLogs(String rootPath, List<ImageUploadItem> items) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ class PreSignedUrlTest {
@DisplayName("PreSigned URL 생성 시 MinIO에서 유효한 URL을 반환한다")
void test_1() {
// given
ImageUploadRequest request = new ImageUploadRequest("review", 1L);
ImageUploadRequest request = new ImageUploadRequest("review", 1L, null);

// when
ImageUploadResponse response = imageUploadService.getPreSignUrl(request);
Expand All @@ -120,7 +120,7 @@ void test_1() {
@DisplayName("PreSigned URL로 실제 파일 업로드가 가능하다")
void test_2() throws Exception {
// given
ImageUploadRequest request = new ImageUploadRequest("review", 1L);
ImageUploadRequest request = new ImageUploadRequest("review", 1L, null);
ImageUploadResponse response = imageUploadService.getPreSignUrl(request);
String uploadUrl = response.imageUploadInfo().get(0).uploadUrl();
byte[] testData = "test image content".getBytes();
Expand Down Expand Up @@ -148,7 +148,7 @@ void test_2() throws Exception {
@DisplayName("업로드된 파일이 MinIO에 존재한다")
void test_3() throws Exception {
// given
ImageUploadRequest request = new ImageUploadRequest("review", 1L);
ImageUploadRequest request = new ImageUploadRequest("review", 1L, null);
ImageUploadResponse response = imageUploadService.getPreSignUrl(request);
String uploadUrl = response.imageUploadInfo().get(0).uploadUrl();
String viewUrl = response.imageUploadInfo().get(0).viewUrl();
Expand Down Expand Up @@ -186,7 +186,7 @@ void test_1() {
Long userId = 1L;
SecurityContextHolder.getContext()
.setAuthentication(new TestingAuthenticationToken(userId.toString(), null));
ImageUploadRequest request = new ImageUploadRequest("review", 2L);
ImageUploadRequest request = new ImageUploadRequest("review", 2L, null);

// when
imageUploadService.getPreSignUrl(request);
Expand All @@ -204,7 +204,7 @@ void test_1() {
@DisplayName("비로그인 사용자가 PreSigned URL 생성 시 로그가 저장되지 않는다")
void test_2() {
// given
ImageUploadRequest request = new ImageUploadRequest("review", 2L);
ImageUploadRequest request = new ImageUploadRequest("review", 2L, null);

// when
imageUploadService.getPreSignUrl(request);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
import app.bottlenote.common.file.dto.response.ImageUploadItem;
import app.bottlenote.common.file.dto.response.ImageUploadResponse;
import app.bottlenote.common.file.exception.FileException;
import app.bottlenote.common.file.exception.FileExceptionCode;
import app.bottlenote.common.file.service.ImageUploadService;
import app.bottlenote.common.file.service.ResourceCommandService;
import app.bottlenote.common.file.upload.fixture.FakeAmazonS3;
import app.bottlenote.common.file.upload.fixture.InMemoryResourceLogRepository;
import java.time.LocalDate;
import java.util.Calendar;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
Expand Down Expand Up @@ -48,14 +50,18 @@ void setUp() {
new ImageUploadService(
resourceCommandService, new FakeAmazonS3(), BUCKET_NAME, CLOUD_FRONT_URL) {
@Override
public String getImageKey(String rootPath, Long index) {
public String getImageKey(String rootPath, Long index, String contentType) {
if (rootPath.startsWith(PATH_DELIMITER)) {
rootPath = rootPath.substring(1);
}
if (rootPath.endsWith(PATH_DELIMITER)) {
rootPath = rootPath.substring(0, rootPath.length() - 1);
}
String imageId = index + KEY_DELIMITER + FAKE_UUID + "." + EXTENSION;
String extension = ALLOWED_CONTENT_TYPES.get(contentType);
if (extension == null) {
throw new FileException(FileExceptionCode.UNSUPPORTED_CONTENT_TYPE);
}
String imageId = index + KEY_DELIMITER + FAKE_UUID + "." + extension;
return rootPath + PATH_DELIMITER + UPLOAD_DATE + PATH_DELIMITER + imageId;
}
};
Expand All @@ -69,10 +75,10 @@ class PreSignedUrlTest {
@DisplayName("PreSignUrl을 생성할 수 있다")
void test_1() {
// given
String imageKey = imageUploadService.getImageKey("review", 1L);
String imageKey = imageUploadService.getImageKey("review", 1L, "image/jpeg");

// when
String preSignUrl = imageUploadService.generatePreSignUrl(imageKey);
String preSignUrl = imageUploadService.generatePreSignUrl(imageKey, "image/jpeg");

// then
log.info("PreSignUrl: {}", preSignUrl);
Expand All @@ -84,7 +90,7 @@ void test_1() {
@DisplayName("업로드용 인증 URL을 생성할 수 있다")
void test_2() {
// given
ImageUploadRequest request = new ImageUploadRequest("review", 2L);
ImageUploadRequest request = new ImageUploadRequest("review", 2L, null);

// when
ImageUploadResponse response = imageUploadService.getPreSignUrl(request);
Expand All @@ -96,8 +102,8 @@ void test_2() {
assertEquals(BUCKET_NAME, response.bucketName());

for (Long index = 1L; index <= response.imageUploadInfo().size(); index++) {
String imageKey = imageUploadService.getImageKey(request.rootPath(), index);
String uploadUrlExpected = imageUploadService.generatePreSignUrl(imageKey);
String imageKey = imageUploadService.getImageKey(request.rootPath(), index, "image/jpeg");
String uploadUrlExpected = imageUploadService.generatePreSignUrl(imageKey, "image/jpeg");
String viewUrlExpected = imageUploadService.generateViewUrl(CLOUD_FRONT_URL, imageKey);

ImageUploadItem info = response.imageUploadInfo().get((int) (index - 1));
Expand All @@ -113,7 +119,7 @@ void test_2() {
@DisplayName("단건 이미지 업로드 URL을 생성할 수 있다")
void test_3() {
// given
ImageUploadRequest request = new ImageUploadRequest("review", 1L);
ImageUploadRequest request = new ImageUploadRequest("review", 1L, null);

// when
ImageUploadResponse response = imageUploadService.getPreSignUrl(request);
Expand All @@ -137,7 +143,7 @@ class ViewUrlTest {
@DisplayName("조회용 URL을 생성할 수 있다")
void test_1() {
// given
String imageKey = imageUploadService.getImageKey("review", 1L);
String imageKey = imageUploadService.getImageKey("review", 1L, "image/jpeg");

// when
String viewUrl = imageUploadService.generateViewUrl(CLOUD_FRONT_URL, imageKey);
Expand All @@ -157,14 +163,62 @@ class ImageKeyTest {
@DisplayName("이미지 루트 경로와 인덱스를 제공해 이미지 키를 생성할 수 있다")
void test_1() {
// given & when
String imageKey = imageUploadService.getImageKey("review", 1L);
String imageKey = imageUploadService.getImageKey("review", 1L, "image/jpeg");
String expected = "review/" + UPLOAD_DATE + "/1-" + FAKE_UUID + ".jpg";

// then
log.info("ImageKey: {}", imageKey);
assertNotNull(imageKey);
assertEquals(expected, imageKey);
}

@Test
@DisplayName("video/mp4 contentType으로 키 생성 시 확장자가 .mp4이다")
void test_2() {
// given & when
String imageKey = imageUploadService.getImageKey("review", 1L, "video/mp4");

// then
log.info("ImageKey: {}", imageKey);
assertTrue(imageKey.endsWith(".mp4"));
}

@Test
@DisplayName("허용된 모든 contentType으로 키를 생성할 수 있다")
void test_3() {
// given
Map<String, String> expectedExtensions =
Map.of(
"image/jpeg",
".jpg",
"image/png",
".png",
"image/webp",
".webp",
"video/mp4",
".mp4");

expectedExtensions.forEach(
(contentType, extension) -> {
// when
String imageKey = imageUploadService.getImageKey("review", 1L, contentType);

// then
log.info("contentType: {} -> ImageKey: {}", contentType, imageKey);
assertTrue(
imageKey.endsWith(extension),
"contentType " + contentType + "의 확장자는 " + extension + "이어야 한다");
});
}

@Test
@DisplayName("허용되지 않은 contentType으로 키 생성 시 예외가 발생한다")
void test_4() {
// given & when & then
assertThrows(
FileException.class,
() -> imageUploadService.getImageKey("review", 1L, "application/pdf"));
}
}

@Nested
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ public URL generatePresignedUrl(GeneratePresignedUrlRequest generatePresignedUrl
throws SdkClientException {
URL url;
try {
url = new URL("http://localhost:8080");
String bucketName = generatePresignedUrlRequest.getBucketName();
String key = generatePresignedUrlRequest.getKey();
url = new URL("https", bucketName + ".s3.amazonaws.com", "/" + key);
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
Expand Down
Loading
Loading