Skip to content
Merged
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ dependencies {
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

// infra
implementation(platform("software.amazon.awssdk:bom:2.20.56"))
implementation("software.amazon.awssdk:s3")
implementation 'io.awspring.cloud:spring-cloud-aws-starter:3.1.1'

}

tasks.named('test') {
Expand Down
31 changes: 31 additions & 0 deletions src/main/java/org/runimo/runimo/config/S3Config.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.runimo.runimo.config;


import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;

@Configuration
public class S3Config {

@Value("${cloud.aws.credentials.access-key}")
private String accessKey;

@Value("${cloud.aws.credentials.secret-key}")
private String secretKey;

@Value("${cloud.aws.region.static}")
private String region;

@Bean
public S3Presigner amazonS3Client() {
return S3Presigner.builder()
.region(Region.of(region))
.credentialsProvider(
() -> software.amazon.awssdk.auth.credentials.AwsBasicCredentials.create(accessKey, secretKey)
)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.runimo.runimo.auth.exceptions.UnRegisteredUserException;
import org.runimo.runimo.auth.exceptions.UserJwtException;
import org.runimo.runimo.common.response.ErrorResponse;
import org.runimo.runimo.external.ExternalServiceException;
import org.runimo.runimo.hatch.exception.HatchException;
import org.runimo.runimo.runimo.exception.RunimoException;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -129,6 +130,14 @@ public ResponseEntity<ErrorResponse> handleIllegalStateException(IllegalStateExc
return ResponseEntity.badRequest().body(ErrorResponse.of("잘못된 요청입니다.", e.getMessage()));
}

@ExceptionHandler(ExternalServiceException.class)
public ResponseEntity<ErrorResponse> handleExternalServiceException(
ExternalServiceException e) {
log.error("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);
return ResponseEntity.status(e.getHttpStatusCode())
.body(ErrorResponse.of(e.getErrorCode()));
}

// Root 서비스 에러 처리
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
Expand Down
41 changes: 41 additions & 0 deletions src/main/java/org/runimo/runimo/external/ExternalResponseCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package org.runimo.runimo.external;

import org.runimo.runimo.exceptions.code.CustomResponseCode;
import org.springframework.http.HttpStatus;

public enum ExternalResponseCode implements CustomResponseCode {
PRESIGNED_URL_FETCHED("ESH2011", HttpStatus.CREATED, "Presigned URL 발급 성공", "Presigned URL 발급 성공"),

PRESIGNED_URL_FETCH_FAILED("ESH4001", HttpStatus.BAD_REQUEST, "Presigned URL 발급 실패", "Presigned URL 발급 실패");
private final String code;
private final HttpStatus httpStatus;
private final String clientMessage;
private final String logMessage;
Comment thread
ekgns33 marked this conversation as resolved.

ExternalResponseCode(String code, HttpStatus httpStatus, String clientMessage, String logMessage) {
this.code = code;
this.httpStatus = httpStatus;
this.clientMessage = clientMessage;
this.logMessage = logMessage;
}

@Override
public String getCode() {
return this.code;
}

@Override
public String getClientMessage() {
return this.clientMessage;
}

@Override
public String getLogMessage() {
return this.logMessage;
}

@Override
public HttpStatus getHttpStatusCode() {
return this.httpStatus;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.runimo.runimo.external;

import org.runimo.runimo.exceptions.BusinessException;
import org.runimo.runimo.exceptions.code.CustomResponseCode;

public class ExternalServiceException extends BusinessException {

protected ExternalServiceException(CustomResponseCode errorCode) {
super(errorCode);
}

public static ExternalServiceException of(CustomResponseCode errorCode) {
return new ExternalServiceException(errorCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.runimo.runimo.external;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.net.URL;
import lombok.RequiredArgsConstructor;
import org.runimo.runimo.common.response.SuccessResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(description = "파일 업로드 관련 API", name = "FILE UPLOAD")
@RestController
@RequestMapping("/api/v1/uploads")
@RequiredArgsConstructor
public class ImageUploadController {

private final S3Service s3Service;

@Operation(summary = "Presigned URL 발급", description = "Presigned URL을 발급합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Presigned URL 발급"),
@ApiResponse(responseCode = "400", description = "데이터 입력 오류"),
@ApiResponse(responseCode = "401", description = "인증 실패"),
@ApiResponse(responseCode = "500", description = "서버 오류"),
})
@PostMapping
public ResponseEntity<SuccessResponse<String>> upload(
@RequestBody ImageUploadRequest request
) {
Comment thread
ekgns33 marked this conversation as resolved.
URL presignedUrl = s3Service.generatePresignedUrl(request.fileName());
return ResponseEntity.status(201)
.body(SuccessResponse.of(
ExternalResponseCode.PRESIGNED_URL_FETCHED,
presignedUrl.toExternalForm()
));
}

}
Comment thread
ekgns33 marked this conversation as resolved.
12 changes: 12 additions & 0 deletions src/main/java/org/runimo/runimo/external/ImageUploadRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.runimo.runimo.external;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;

@Schema(description = "이미지 업로드 url발급 요청")
public record ImageUploadRequest(
@Schema(description = "파일명")
@NotEmpty String fileName
) {

}
62 changes: 62 additions & 0 deletions src/main/java/org/runimo/runimo/external/S3Service.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package org.runimo.runimo.external;

import java.net.URL;
import java.time.Duration;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;

@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {

private final S3Presigner s3Presigner;
@Value("${cloud.aws.s3.bucket}")
private String bucketName;

public URL generatePresignedUrl(String fileName) {
validateFileName(fileName);
String objectKey = "uploads/" + UUID.randomUUID() + "_" + sanitizeFileName(fileName);
try {
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(10)) // The URL expires in 10 minutes.
.putObjectRequest(createPutObjectRequest(bucketName, objectKey))
.build();
PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest);
log.debug("Presigned URL: [{}]", presignedRequest.url().toString());
return presignedRequest.url();
} catch (Exception e) {
log.error("Error generating presigned URL: {}", e.getMessage());
throw ExternalServiceException.of(ExternalResponseCode.PRESIGNED_URL_FETCH_FAILED);
}
}

private void validateFileName(String fileName) {
if (fileName == null || fileName.isEmpty()) {
throw new IllegalArgumentException("파일명이 비어있습니다.");
}
if (fileName.length() > 255) {
throw new IllegalArgumentException("파일명이 너무 깁니다. 최대 255자까지 가능합니다.");
}
}

private String sanitizeFileName(String fileName) {
String sanitized = fileName.replaceAll("\\.\\.[\\\\/]", "");
sanitized = sanitized.replaceAll("[^a-zA-Z0-9._-]", "_");
return sanitized;
}

private PutObjectRequest createPutObjectRequest(String bucketName, String objectKey) {
return PutObjectRequest.builder()
.bucket(bucketName)
.key(objectKey)
.build();
}
}
9 changes: 9 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ spring:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v1/oidc/userinfo
cloud:
aws:
credentials:
access-key: ${AWS_ACCESS_KEY}
secret-key: ${AWS_SECRET_KEY}
region:
static: ${AWS_REGION}
s3:
bucket: ${AWS_S3_BUCKET_NAME}
apple:
client-id: ${APPLE_CLIENT_ID}
client-secret: ${APPLE_PRIVATE_KEY}
Expand Down
10 changes: 10 additions & 0 deletions src/test/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ jwt:
temp:
expiration: ${JWT_REGISTER_EXPIRATION}

cloud:
aws:
credentials:
access-key: ${AWS_ACCESS_KEY}
secret-key: ${AWS_SECRET_KEY}
region:
static: ${AWS_REGION}
s3:
bucket: ${AWS_S3_BUCKET_NAME}

apple:
client-id: ${APPLE_CLIENT_ID}
client-secret: ${APPLE_PRIVATE_KEY}
Expand Down