diff --git a/build.gradle b/build.gradle index dc91a43..bba05d2 100644 --- a/build.gradle +++ b/build.gradle @@ -45,7 +45,7 @@ dependencies { testRuntimeOnly 'com.h2database:h2' // 스웨거 - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0' // 스프링 시큐리티 implementation 'org.springframework.boot:spring-boot-starter-security' @@ -60,7 +60,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' // s3 - implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + implementation(platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1")) + implementation("io.awspring.cloud:spring-cloud-aws-starter-s3") // oauth2 implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' diff --git a/src/main/java/umc/codeplay/apiPayLoad/code/status/ErrorStatus.java b/src/main/java/umc/codeplay/apiPayLoad/code/status/ErrorStatus.java index bd22056..30ebaa6 100644 --- a/src/main/java/umc/codeplay/apiPayLoad/code/status/ErrorStatus.java +++ b/src/main/java/umc/codeplay/apiPayLoad/code/status/ErrorStatus.java @@ -30,6 +30,10 @@ public enum ErrorStatus implements BaseErrorCode { INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH406", "유효하지 않은 OAuth 제공자입니다."), AWS_SERVICE_UNAVAILABLE(HttpStatus.BAD_REQUEST, "AWS400", "AWS S3에 파일을 업로드할 수 없습니다."), + AWS_METHOD_NOT_ALLOWED( + HttpStatus.METHOD_NOT_ALLOWED, + "AWS405", + "AWS S3 presigned url에서 해당 method는 허용되지 않습니다."), MUSIC_NOT_FOUND(HttpStatus.BAD_REQUEST, "MUSIC400", "음원을 찾을 수 없습니다."), diff --git a/src/main/java/umc/codeplay/config/AWSConfig.java b/src/main/java/umc/codeplay/config/AWSConfig.java index f521913..b79885c 100644 --- a/src/main/java/umc/codeplay/config/AWSConfig.java +++ b/src/main/java/umc/codeplay/config/AWSConfig.java @@ -4,10 +4,10 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.services.s3.AmazonS3Client; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; +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.presigner.S3Presigner; @Configuration public class AWSConfig { @@ -22,13 +22,12 @@ public class AWSConfig { private String region; @Bean - public AmazonS3Client amazonS3Client() { - final BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); - - return (AmazonS3Client) - AmazonS3ClientBuilder.standard() - .withRegion(region) - .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) - .build(); + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .build(); } } diff --git a/src/main/java/umc/codeplay/controller/FileController.java b/src/main/java/umc/codeplay/controller/FileController.java new file mode 100644 index 0000000..d22fab0 --- /dev/null +++ b/src/main/java/umc/codeplay/controller/FileController.java @@ -0,0 +1,49 @@ +package umc.codeplay.controller; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import lombok.RequiredArgsConstructor; + +import io.swagger.v3.oas.annotations.Operation; +import software.amazon.awssdk.http.SdkHttpMethod; +import umc.codeplay.apiPayLoad.ApiResponse; +import umc.codeplay.dto.FileResponseDTO; +import umc.codeplay.service.FileService; + +import static umc.codeplay.service.FileService.buildFilename; + +@RestController +@RequestMapping("/files") +@RequiredArgsConstructor +public class FileController { + + private final FileService fileService; + + @Operation( + summary = "Download용 Presigned URL 생성", + description = "다운로드를 위한 Presigned URL 생성 - 유효시간 존재") + @GetMapping("/download") + public ApiResponse getUrl( + @RequestParam(value = "fileName") String fileName) { + String downloadUrl = fileService.generatePreSignedUrl(fileName, SdkHttpMethod.GET); + FileResponseDTO.DownloadFile result = new FileResponseDTO.DownloadFile(downloadUrl); + + return ApiResponse.onSuccess(result); + } + + @Operation( + summary = "Upload용 Presigned URL 생성", + description = "업로드를 위한 Presigned URL 생성 - 유효시간 존재") + @PostMapping("/upload") + public ApiResponse generateUrl( + @RequestParam(value = "fileName") String fileName) { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + String newFileName = buildFilename(fileName); + Long musicId = fileService.uploadMusic(newFileName, username); + + String uploadUrl = fileService.generatePreSignedUrl(newFileName, SdkHttpMethod.PUT); + FileResponseDTO.UploadFile result = new FileResponseDTO.UploadFile(uploadUrl, musicId); + return ApiResponse.onSuccess(result); + } +} diff --git a/src/main/java/umc/codeplay/dto/FileResponseDTO.java b/src/main/java/umc/codeplay/dto/FileResponseDTO.java new file mode 100644 index 0000000..3fd5f41 --- /dev/null +++ b/src/main/java/umc/codeplay/dto/FileResponseDTO.java @@ -0,0 +1,20 @@ +package umc.codeplay.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +public class FileResponseDTO { + + @Getter + @AllArgsConstructor + public static class DownloadFile { + private String downloadS3Url; + } + + @Getter + @AllArgsConstructor + public static class UploadFile { + private String uploadS3Url; + private Long musicId; + } +} diff --git a/src/main/java/umc/codeplay/service/FileService.java b/src/main/java/umc/codeplay/service/FileService.java new file mode 100644 index 0000000..4ad14e8 --- /dev/null +++ b/src/main/java/umc/codeplay/service/FileService.java @@ -0,0 +1,108 @@ +package umc.codeplay.service; + +import java.text.Normalizer; +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import umc.codeplay.apiPayLoad.code.status.ErrorStatus; +import umc.codeplay.apiPayLoad.exception.handler.GeneralHandler; +import umc.codeplay.domain.Member; +import umc.codeplay.domain.Music; +import umc.codeplay.repository.MemberRepository; +import umc.codeplay.repository.MusicRepository; + +@Service +@RequiredArgsConstructor +public class FileService { + + @Value("${cloud.aws.s3.bucket}") + private String bucketName; + + @Value("${cloud.aws.region.static}") + private String region; + + private final S3Presigner s3Presigner; + private final MusicRepository musicRepository; + private final MemberRepository memberRepository; + + // 타임스탬프_파일명 형식으로 파일 이름 저장 + public static String buildFilename(String filename) { + return String.format("%s_%s", System.currentTimeMillis(), sanitizeFileName(filename)); + } + + // 특수 문자나 공백 등을 정리 + private static String sanitizeFileName(String fileName) { + String normalizedFileName = Normalizer.normalize(fileName, Normalizer.Form.NFC); + return normalizedFileName.replaceAll("\\s+", "_").replaceAll("[^a-zA-Z0-9.\\-_]", ""); + } + + // 파일 업로드(HTTP PUT) 또는 다운로드(HTTP GET)를 위한 Presigned URL 생성 + public String generatePreSignedUrl(String fileName, SdkHttpMethod method) { + + return switch (method) { + case GET -> generateGetPresignedUrl(fileName); + case PUT -> generatePutPresignedUrl(fileName); + default -> throw new GeneralHandler(ErrorStatus.AWS_SERVICE_UNAVAILABLE); + }; + } + + // S3에서 파일을 다운로드할 수 있는 Presigned URL 생성 + private String generateGetPresignedUrl(String fileName) { + GetObjectRequest getObjectRequest = + GetObjectRequest.builder().bucket(bucketName).key(fileName).build(); + + GetObjectPresignRequest presignRequest = + GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(60)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + + // S3에 파일을 업로드할 수 있는 Presigned URL 생성 + private String generatePutPresignedUrl(String fileName) { + PutObjectRequest putObjectRequest = + PutObjectRequest.builder().bucket(bucketName).key(fileName).build(); + + PutObjectPresignRequest presignRequest = + PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(60)) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + return presignedRequest.url().toString(); + } + + // music 레포지토리에 업로드 + public Long uploadMusic(String newFileName, String userEmail) { + Member member = + memberRepository + .findByEmail(userEmail) + .orElseThrow(() -> new GeneralHandler(ErrorStatus.MEMBER_NOT_FOUND)); + + // 저장하는 url은 유효시간이 없는 public + // TODO: 업로드에만 presigned 사용할지 아님 다운로드시에도 사용할지에 따라 변경해야함. + String s3Url = + String.format("https://%s.s3.%s.amazonaws.com/%s", bucketName, region, newFileName); + Music newMusic = Music.builder().title(newFileName).musicUrl(s3Url).member(member).build(); + + return musicRepository.save(newMusic).getId(); + } + + // TODO: 필요시 직접 업로드 방법 구현 필요 +} diff --git a/src/main/java/umc/codeplay/service/S3Service.java b/src/main/java/umc/codeplay/service/S3Service.java deleted file mode 100644 index 1c58580..0000000 --- a/src/main/java/umc/codeplay/service/S3Service.java +++ /dev/null @@ -1,87 +0,0 @@ -package umc.codeplay.service; - -import java.io.ByteArrayInputStream; -import java.io.IOException; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -import lombok.RequiredArgsConstructor; - -import com.amazonaws.AmazonClientException; -import com.amazonaws.services.s3.AmazonS3Client; -import com.amazonaws.services.s3.model.ObjectMetadata; -import com.amazonaws.services.s3.model.PutObjectRequest; -import com.amazonaws.util.IOUtils; -import umc.codeplay.apiPayLoad.code.status.ErrorStatus; -import umc.codeplay.apiPayLoad.exception.handler.GeneralHandler; - -@Service -@RequiredArgsConstructor -@Transactional -public class S3Service { - - @Value("${cloud.aws.s3.bucket}") - private String bucket; - - private final AmazonS3Client amazonS3Client; - - /* - s3에 파일 업로드 - */ - public String uploadFile(MultipartFile file) { - if (file.getOriginalFilename() == null || file.getOriginalFilename().isEmpty()) { - throw new GeneralHandler(ErrorStatus._BAD_REQUEST); - } - - final String fileName = newFileName(file.getOriginalFilename()); - - ObjectMetadata objectMetadata = new ObjectMetadata(); - try { - objectMetadata.setContentLength(file.getSize()); - objectMetadata.setContentType(file.getContentType()); - - /* - TODO: 용량 문제가 생긴다면 아래 ByteArrayInputStream 방식을 변경해야함 - 1. 임시파일 방식으로 변경 - 2. 가능한 용량 limit 설정 - */ - byte[] bytes = IOUtils.toByteArray(file.getInputStream()); - ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes); - - PutObjectRequest putObjectRequest = - new PutObjectRequest(bucket, fileName, inputStream, objectMetadata); - amazonS3Client.putObject(putObjectRequest); - - inputStream.close(); - - } catch (AmazonClientException | IOException e) { - throw new GeneralHandler(ErrorStatus.AWS_SERVICE_UNAVAILABLE); - } - - return amazonS3Client.getUrl(bucket, fileName).toString(); - } - - /* - s3 업로드시 파일 이름 변경 - */ - private String newFileName(String fileName) { - - final String FILE_EXTENSION_SEPARATOR = "."; - final String now = String.valueOf(System.currentTimeMillis()); - - int fileExtensionIndex = fileName.lastIndexOf(FILE_EXTENSION_SEPARATOR); - if (fileExtensionIndex == -1) { - return fileName - + "_" - + System.currentTimeMillis(); // No extension found, just add timestamp - } - - final String fileExtension = fileName.substring(fileExtensionIndex); - final String originalFileName = fileName.substring(0, fileExtensionIndex); - - return originalFileName + "_" + now + fileExtension; - } -} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 8518a34..4ffb3ed 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -32,8 +32,8 @@ cloud: aws: s3: bucket: ${S3_BUCKET} - stack.auto: false - region.static: ${AWS_DEFAULT_REGION} + region: + static: ${AWS_DEFAULT_REGION} credentials: accessKey: ${AWS_ACCESS_KEY_ID} secretKey: ${AWS_SECRET_ACCESS_KEY} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7d2b15d..b59e8b8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -32,8 +32,8 @@ cloud: aws: s3: bucket: ${S3_BUCKET} - stack.auto: false - region.static: ${AWS_DEFAULT_REGION} + region: + static: ${AWS_DEFAULT_REGION} credentials: accessKey: ${AWS_ACCESS_KEY_ID} secretKey: ${AWS_SECRET_ACCESS_KEY}