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
3 changes: 2 additions & 1 deletion bottlenote-admin-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ dependencies {
testImplementation(libs.bundles.testcontainers.complete)

// Test - AWS S3 (for MinIO integration test)
testImplementation(libs.aws.s3)
testImplementation(platform(libs.aws.sdk.bom))
testImplementation(libs.aws.sdk.s3)
}

sourceSets {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,26 @@ package app.integration.file

import app.IntegrationTestSupport
import app.bottlenote.operation.utils.TestContainersConfig
import com.amazonaws.services.s3.AmazonS3
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.model.GetObjectRequest
import software.amazon.awssdk.services.s3.model.HeadObjectRequest
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URI
import java.nio.charset.StandardCharsets

@Tag("admin_integration")
@DisplayName("[integration] Admin Image Upload API 통합 테스트")
class AdminImageUploadIntegrationTest : IntegrationTestSupport() {
@Autowired
private lateinit var amazonS3: AmazonS3
private lateinit var s3Client: S3Client

private lateinit var accessToken: String

Expand Down Expand Up @@ -182,12 +185,26 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() {

// then: S3에서 파일 존재 확인
val bucketName = TestContainersConfig.getTestBucket()
val exists = amazonS3.doesObjectExist(bucketName, s3Key)
assertThat(exists).isEqualTo(true)
s3Client.headObject(
HeadObjectRequest
.builder()
.bucket(bucketName)
.key(s3Key)
.build()
)

// then: 업로드된 내용 확인
val s3Object = amazonS3.getObject(bucketName, s3Key)
val content = s3Object.objectContent.bufferedReader().use { it.readText() }
val content =
s3Client
.getObject(
GetObjectRequest
.builder()
.bucket(bucketName)
.key(s3Key)
.build()
)
.bufferedReader(StandardCharsets.UTF_8)
.use { it.readText() }
assertThat(content).isEqualTo(testContent)

log.info("업로드된 파일 내용 확인 완료: {}", content)
Expand Down Expand Up @@ -229,13 +246,24 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() {
val bucketName = TestContainersConfig.getTestBucket()
uploadResults.forEach { (s3Key, expectedContent, responseCode) ->
assertThat(responseCode).isEqualTo(200)
assertThat(amazonS3.doesObjectExist(bucketName, s3Key)).isEqualTo(true)
s3Client.headObject(
HeadObjectRequest
.builder()
.bucket(bucketName)
.key(s3Key)
.build()
)

val actualContent =
amazonS3
.getObject(bucketName, s3Key)
.objectContent
.bufferedReader()
s3Client
.getObject(
GetObjectRequest
.builder()
.bucket(bucketName)
.key(s3Key)
.build()
)
.bufferedReader(StandardCharsets.UTF_8)
.use { it.readText() }
assertThat(actualContent).isEqualTo(expectedContent)
}
Expand Down
4 changes: 3 additions & 1 deletion bottlenote-mono/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ dependencies {

// ===== Text Processing =====
implementation libs.ahocorasick
implementation libs.commons.codec

// ===== Security =====
implementation libs.spring.boot.starter.security
Expand All @@ -32,7 +33,8 @@ dependencies {
implementation libs.google.guava

// ===== External Services =====
implementation libs.aws.s3
implementation platform(libs.aws.sdk.bom)
implementation libs.aws.sdk.s3
implementation libs.spring.cloud.starter.openfeign

// ===== Observability =====
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public void handleImageResourceActivated(ImageResourceActivatedEvent event) {

for (String resourceKey : event.resourceKeys()) {
resourceCommandService.activateImageResource(
resourceKey, event.referenceId(), event.referenceType());
resourceKey, event.referenceId(), event.referenceType(), event.userId());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import java.util.Objects;

public record ImageResourceActivatedEvent(
List<String> resourceKeys, Long referenceId, String referenceType) {
List<String> resourceKeys, Long referenceId, String referenceType, Long userId) {

public ImageResourceActivatedEvent {
Objects.requireNonNull(resourceKeys, "resourceKeys must not be null");
Expand All @@ -14,11 +14,22 @@ public record ImageResourceActivatedEvent(

public static ImageResourceActivatedEvent of(
List<String> resourceKeys, Long referenceId, String referenceType) {
return new ImageResourceActivatedEvent(resourceKeys, referenceId, referenceType);
return new ImageResourceActivatedEvent(resourceKeys, referenceId, referenceType, null);
}

public static ImageResourceActivatedEvent of(
String resourceKey, Long referenceId, String referenceType) {
return new ImageResourceActivatedEvent(List.of(resourceKey), referenceId, referenceType);
return new ImageResourceActivatedEvent(List.of(resourceKey), referenceId, referenceType, null);
}

public static ImageResourceActivatedEvent of(
List<String> resourceKeys, Long referenceId, String referenceType, Long userId) {
return new ImageResourceActivatedEvent(resourceKeys, referenceId, referenceType, userId);
}

public static ImageResourceActivatedEvent of(
String resourceKey, Long referenceId, String referenceType, Long userId) {
return new ImageResourceActivatedEvent(
List.of(resourceKey), referenceId, referenceType, userId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@

public enum FileExceptionCode implements ExceptionCode {
EXPIRY_TIME_RANGE_INVALID(HttpStatus.BAD_REQUEST, "만료 기간의 범위가 적절하지 않습니다.( 최소 1분 ,최대 10분) "),
UNSUPPORTED_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 Content-Type입니다.");
UNSUPPORTED_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 Content-Type입니다."),
INVALID_RESOURCE_URL(HttpStatus.BAD_REQUEST, "유효하지 않은 리소스 URL입니다."),
RESOURCE_NOT_FOUND(HttpStatus.BAD_REQUEST, "등록되지 않은 리소스입니다."),
RESOURCE_OWNER_MISMATCH(HttpStatus.BAD_REQUEST, "리소스 소유자가 일치하지 않습니다."),
RESOURCE_ALREADY_USED(HttpStatus.BAD_REQUEST, "사용할 수 없는 리소스 상태입니다.");

private final HttpStatus httpStatus;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,34 @@
import app.bottlenote.common.file.dto.response.ImageUploadItem;
import app.bottlenote.common.file.dto.response.ImageUploadResponse;
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.time.Duration;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;

@Slf4j
@ThirdPartyService
public class ImageUploadService implements PreSignUrlProvider {

private static final Integer EXPIRY_TIME = 5;
private final ResourceCommandService resourceCommandService;
private final AmazonS3 amazonS3;
private final S3Presigner s3Presigner;
private final String imageBucketName;
private final String cloudFrontUrl;

public ImageUploadService(
ResourceCommandService resourceCommandService,
AmazonS3 amazonS3,
S3Presigner s3Presigner,
@Value("${amazon.aws.bucket}") String imageBucketName,
@Value("${amazon.aws.cloudFrontUrl}") String cloudFrontUrl) {
this.resourceCommandService = resourceCommandService;
this.amazonS3 = amazonS3;
this.s3Presigner = s3Presigner;
this.imageBucketName = imageBucketName;
this.cloudFrontUrl = cloudFrontUrl;
this.cloudFrontUrl = normalizeCloudFrontUrl(cloudFrontUrl);
}

/**
Expand Down Expand Up @@ -95,18 +95,23 @@ private ImageUploadResponse buildResponse(List<ImageUploadItem> keys) {

@Override
public String generateViewUrl(String cloudFrontUrl, String imageKey) {
return cloudFrontUrl + PATH_DELIMITER + imageKey;
return normalizeCloudFrontUrl(cloudFrontUrl) + PATH_DELIMITER + imageKey;
}

@Override
public String generatePreSignUrl(String imageKey, String contentType) {
Calendar uploadExpiryTime = getUploadExpiryTime(EXPIRY_TIME);
GeneratePresignedUrlRequest request =
new GeneratePresignedUrlRequest(imageBucketName, imageKey)
.withMethod(HttpMethod.PUT)
.withExpiration(uploadExpiryTime.getTime())
.withContentType(contentType);
return amazonS3.generatePresignedUrl(request).toString();
PutObjectRequest putObjectRequest =
PutObjectRequest.builder()
.bucket(imageBucketName)
.key(imageKey)
.contentType(contentType)
.build();
PutObjectPresignRequest presignRequest =
PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(EXPIRY_TIME))
.putObjectRequest(putObjectRequest)
.build();
return s3Presigner.presignPutObject(presignRequest).url().toString();
}

private void saveImageUploadLogs(String rootPath, List<ImageUploadItem> items) {
Expand Down Expand Up @@ -134,4 +139,12 @@ private String extractImageKey(String viewUrl) {
int lastSlashOfCloudFront = cloudFrontUrl.length() + 1;
return viewUrl.substring(lastSlashOfCloudFront);
}

private String normalizeCloudFrontUrl(String url) {
String normalized = url;
while (normalized.endsWith(PATH_DELIMITER)) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,8 @@ public class ResourceCommandService {

private final ResourceLogRepository resourceLogRepository;

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
public CompletableFuture<ResourceLogResponse> saveImageResourceCreated(
ResourceLogRequest request) {
public ResourceLogResponse saveImageResourceCreated(ResourceLogRequest request) {
ResourceLog entity =
ResourceLog.builder()
.userId(request.userId())
Expand All @@ -45,13 +43,20 @@ public CompletableFuture<ResourceLogResponse> saveImageResourceCreated(
"이미지 리소스 생성 로그 저장 - resourceKey: {}, userId: {}",
saved.getResourceKey(),
saved.getUserId());
return CompletableFuture.completedFuture(toResponse(saved));
return toResponse(saved);
}

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
public CompletableFuture<Optional<ResourceLogResponse>> activateImageResource(
String resourceKey, Long referenceId, String referenceType) {
return activateImageResource(resourceKey, referenceId, referenceType, null);
}

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
public CompletableFuture<Optional<ResourceLogResponse>> activateImageResource(
String resourceKey, Long referenceId, String referenceType, Long expectedUserId) {
Optional<ResourceLog> resourceLogOpt = resourceLogRepository.findByResourceKey(resourceKey);

if (resourceLogOpt.isEmpty()) {
Expand All @@ -61,6 +66,15 @@ public CompletableFuture<Optional<ResourceLogResponse>> activateImageResource(

ResourceLog resourceLog = resourceLogOpt.get();

if (expectedUserId != null && !expectedUserId.equals(resourceLog.getUserId())) {
log.warn(
"리소스 소유자가 일치하지 않아 활성화 스킵 - resourceKey: {}, expectedUserId: {}, actualUserId: {}",
resourceKey,
expectedUserId,
resourceLog.getUserId());
return CompletableFuture.completedFuture(Optional.empty());
}

if (resourceLog.isActivated()) {
log.info("이미 활성화된 리소스 스킵 - resourceKey: {}", resourceKey);
return CompletableFuture.completedFuture(Optional.empty());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package app.bottlenote.common.file.service;

import static app.bottlenote.common.file.constant.ResourceEventType.ACTIVATED;
import static app.bottlenote.common.file.constant.ResourceEventType.CREATED;
import static app.bottlenote.common.file.exception.FileExceptionCode.INVALID_RESOURCE_URL;
import static app.bottlenote.common.file.exception.FileExceptionCode.RESOURCE_ALREADY_USED;
import static app.bottlenote.common.file.exception.FileExceptionCode.RESOURCE_NOT_FOUND;
import static app.bottlenote.common.file.exception.FileExceptionCode.RESOURCE_OWNER_MISMATCH;

import app.bottlenote.common.file.domain.ResourceLog;
import app.bottlenote.common.file.domain.ResourceLogRepository;
import app.bottlenote.common.file.exception.FileException;
import app.bottlenote.common.image.ImageUtil;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class ResourceVerifierService {

private final ResourceLogRepository resourceLogRepository;

@Transactional(readOnly = true)
public List<String> verifyOwnedImageResources(
List<String> viewUrls, Long userId, Long referenceId, String referenceType) {
return Objects.requireNonNullElse(viewUrls, Collections.<String>emptyList()).stream()
.map(viewUrl -> verifyOwnedImageResource(viewUrl, userId, referenceId, referenceType))
.toList();
}

private String verifyOwnedImageResource(
String viewUrl, Long userId, Long referenceId, String referenceType) {
String resourceKey = ImageUtil.extractResourceKey(viewUrl);
if (resourceKey == null || resourceKey.isBlank()) {
throw new FileException(INVALID_RESOURCE_URL);
}

ResourceLog resourceLog =
resourceLogRepository
.findByResourceKey(resourceKey)
.orElseThrow(() -> new FileException(RESOURCE_NOT_FOUND));

if (!Objects.equals(userId, resourceLog.getUserId())) {
throw new FileException(RESOURCE_OWNER_MISMATCH);
}
if (!Objects.equals(viewUrl, resourceLog.getViewUrl())) {
throw new FileException(INVALID_RESOURCE_URL);
}
if (!isUsable(resourceLog, referenceId, referenceType)) {
throw new FileException(RESOURCE_ALREADY_USED);
}
return resourceKey;
}

private boolean isUsable(ResourceLog resourceLog, Long referenceId, String referenceType) {
if (resourceLog.getEventType() == CREATED) {
return true;
}
return resourceLog.getEventType() == ACTIVATED
&& Objects.equals(referenceId, resourceLog.getReferenceId())
&& Objects.equals(referenceType, resourceLog.getReferenceType());
}
}
Loading
Loading