Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT] presignedUrl 생성 로직 구현 #324

Merged
merged 4 commits into from
Aug 24, 2024
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
3 changes: 3 additions & 0 deletions main/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ dependencies {
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

// AWS SDK for S3
implementation "software.amazon.awssdk:s3:2.27.0"
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public enum ErrorStatus {
* 500 SERVER_ERROR
*/
NOTIFICATION_SERVER_ERROR("알림 서버에 에러가 발생했습니다."),
S3_STORAGE_ERROR("s3 스토리지에 에러가 발생했습니다."),
INTERNAL_SERVER_ERROR("예상치 못한 서버 에러가 발생했습니다.");

private final String errorCode;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.sopt.makers.crew.main.external.s3.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
@ConfigurationProperties("aws-property")
public final class AwsProperties {
private final String awsRegion;
private final String s3BucketName;
private final String accessKey;
private final String secretKey;
private final long fileMinSize;
private final long fileMaxSize;
private final String algorithm;
private final String contentType;
private final String requestType;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.sopt.makers.crew.main.external.s3.config;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties(value = {AwsProperties.class})
public class AwsPropertiesConfiguration {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package org.sopt.makers.crew.main.external.s3.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import lombok.RequiredArgsConstructor;
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.presigner.S3Presigner;

@Configuration
@RequiredArgsConstructor
public class S3Config {

private final AwsProperties awsProperties;

@Bean
public S3Client s3Client() {
AwsBasicCredentials awsBasicCredentials = getAwsBasicCredentials();

return S3Client.builder()
.region(Region.of(awsProperties.getAwsRegion()))
.credentialsProvider(StaticCredentialsProvider.create(awsBasicCredentials))
.build();
}

@Bean
public S3Presigner s3Presigner() {
AwsBasicCredentials awsBasicCredentials = getAwsBasicCredentials();

return S3Presigner.builder()
.region(Region.of(awsProperties.getAwsRegion()))
.credentialsProvider(StaticCredentialsProvider.create(awsBasicCredentials))
.build();
}

private AwsBasicCredentials getAwsBasicCredentials() {
return AwsBasicCredentials.create(
awsProperties.getAccessKey(),
awsProperties.getSecretKey());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package org.sopt.makers.crew.main.external.s3.service;

import static org.sopt.makers.crew.main.common.exception.ErrorStatus.*;

import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.SimpleTimeZone;
import java.util.UUID;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.sopt.makers.crew.main.common.exception.ServerException;
import org.sopt.makers.crew.main.external.s3.config.AwsProperties;
import org.sopt.makers.crew.main.meeting.v2.dto.response.PreSignedUrlFieldResponseDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.PreSignedUrlResponseDto;
import org.springframework.stereotype.Service;

import com.fasterxml.jackson.databind.ObjectMapper;

import lombok.RequiredArgsConstructor;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.utils.BinaryUtils;

@Service
@RequiredArgsConstructor
public class S3Service {
private static final String MEETING_PATH = "meeting";
private static final String S3_SERVCIE = "s3";
private static final String PRE_SIGNED_URL_PREFIX = "https://s3.ap-northeast-2.amazonaws.com";

private final S3Client s3Client;

private final AwsProperties awsProperties;

public PreSignedUrlResponseDto generatePreSignedUrl(String contentType) {
SimpleDateFormat dateTimeFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
dateTimeFormat.setTimeZone(new SimpleTimeZone(0, "UTC"));
SimpleDateFormat dateStampFormat = new SimpleDateFormat("yyyyMMdd");
dateStampFormat.setTimeZone(new SimpleTimeZone(0, "UTC"));

Date now = new Date();
String dateTimeStamp = dateTimeFormat.format(now);
String dateStamp = dateStampFormat.format(now);

Calendar calendar = Calendar.getInstance();
calendar.setTime(now);
calendar.add(Calendar.MINUTE, 10);
Date expirationDate = calendar.getTime();

String objectKey = getObjectKey(contentType);
String encodedPolicy = generateEncodedPolicy(awsProperties.getS3BucketName(), objectKey, dateTimeStamp,
dateStamp, expirationDate);

String signature = sign(encodedPolicy, dateStamp, S3_SERVCIE);
String credentials = getCredentials(dateStamp);

PreSignedUrlFieldResponseDto fieldResponseDto = new PreSignedUrlFieldResponseDto(awsProperties.getContentType(),
objectKey, awsProperties.getS3BucketName(), awsProperties.getAlgorithm(), credentials, dateTimeStamp,
encodedPolicy,
signature);

return PreSignedUrlResponseDto.of(PRE_SIGNED_URL_PREFIX + "/" + awsProperties.getS3BucketName(),
fieldResponseDto);
}

/**
* 파일의 경로명을 랜덤으로 생성
* @author @mikekks
* @param contentType 파일의 컨텐츠 타입
* @returns 파일의 경로명
*/
private String getObjectKey(String contentType) {
UUID uuid = UUID.randomUUID();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd");
String curDate = LocalDateTime.now().format(formatter);

return MEETING_PATH + "/" + curDate + "/" + uuid + "." + contentType;
}

/**
* PreSignedUrl 업로드 정책 생성
* @author @mikekks
* @returns base64로 인코딩된 String
*/
public String generateEncodedPolicy(String bucketName, String objectKey, String dateTimeStamp, String dateStamp,
Date expirationDate) {
List<?> conditions = generateConditions(bucketName, objectKey, dateTimeStamp,
dateStamp);

SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
simpleDateFormat.setTimeZone(new SimpleTimeZone(0, "UTC"));

Map<String, Object> policy = Map.of(
"expiration", simpleDateFormat.format(expirationDate),
"conditions", conditions
);

return encodePolicy(policy);
}

private List<?> generateConditions(String bucketName, String objectKey, String dateTimeStamp, String dateStamp) {
List<?> contentLengthRange = List.of("content-length-range", awsProperties.getFileMinSize(),
awsProperties.getFileMaxSize());

List<Object> conditions = new LinkedList<>();
conditions.add(contentLengthRange);

Map<String, String> params = new HashMap<>();
String credentials = getCredentials(dateStamp);
params.put("Content-Type", awsProperties.getContentType());
params.put("key", objectKey);
params.put("bucket", bucketName);
params.put("X-Amz-Algorithm", awsProperties.getAlgorithm());
params.put("X-Amz-Credential", credentials);
params.put("X-Amz-Date", dateTimeStamp);

Map<String, String> awsDefaults = generateAwsDefaultParams(bucketName, objectKey, dateTimeStamp, dateStamp);

conditions.addAll(awsDefaults.entrySet());

return conditions;
}

private Map<String, String> generateAwsDefaultParams(String bucketName, String objectKey, String dateTimeStamp,
String dateStamp) {

String credentials = getCredentials(dateStamp);
Map<String, String> params = new HashMap<>();
params.put("Content-Type", awsProperties.getContentType());
params.put("key", objectKey);
params.put("bucket", bucketName);
params.put("X-Amz-Algorithm", awsProperties.getAlgorithm());
params.put("X-Amz-Credential", credentials);
params.put("X-Amz-Date", dateTimeStamp);

return params;
}

private String getCredentials(String dateStamp) {
return String.format("%s/%s/%s/%s/%s", awsProperties.getAccessKey(), dateStamp, awsProperties.getAwsRegion(),
S3_SERVCIE,
awsProperties.getRequestType());
}

private String encodePolicy(Map<String, Object> policy) {
try {
ObjectMapper objectMapper = new ObjectMapper();
String jsonPolicy = objectMapper.writeValueAsString(policy);

return Base64.getEncoder().encodeToString(jsonPolicy.getBytes());
} catch (Exception e) {
throw new ServerException(S3_STORAGE_ERROR.getErrorCode());
}
}

public String sign(String toSign, String dateStamp, String service) {
try {
String awsSecretKey = awsProperties.getSecretKey();

byte[] kSecret = ("AWS4" + awsSecretKey).getBytes(StandardCharsets.UTF_8);
byte[] kDate = hmacSHA256(dateStamp, kSecret);
byte[] kRegion = hmacSHA256("ap-northeast-2", kDate);
byte[] kService = hmacSHA256(service, kRegion);
byte[] kSigning = hmacSHA256("aws4_request", kService);
byte[] signature = hmacSHA256(toSign, kSigning);

return BinaryUtils.toHex(signature);
} catch (Exception e) {
throw new ServerException(S3_STORAGE_ERROR.getErrorCode());
}
}

private byte[] hmacSHA256(String data, byte[] key) {
try {
String algorithm = "HmacSHA256";
Mac mac = Mac.getInstance(algorithm);
mac.init(new SecretKeySpec(key, algorithm));
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new ServerException(S3_STORAGE_ERROR.getErrorCode());
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.websocket.server.PathParam;

import java.security.Principal;
import java.util.List;

Expand All @@ -20,12 +22,14 @@
import org.sopt.makers.crew.main.meeting.v2.dto.request.ApplyV2UpdateStatusBodyDto;
import org.sopt.makers.crew.main.meeting.v2.dto.request.MeetingV2ApplyMeetingDto;
import org.sopt.makers.crew.main.meeting.v2.dto.request.MeetingV2CreateMeetingBodyDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.AppliesCsvFileUrlResponseDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.MeetingGetApplyListResponseDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.MeetingV2ApplyMeetingResponseDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.MeetingV2CreateMeetingResponseDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.MeetingV2GetAllMeetingByOrgUserDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.MeetingV2GetAllMeetingDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.MeetingV2GetMeetingBannerResponseDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.PreSignedUrlResponseDto;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
Expand Down Expand Up @@ -114,4 +118,15 @@ ResponseEntity<TempResponseDto<MeetingV2GetAllMeetingDto>> getMeetingsTemp(@Mode

@Operation(summary = "모임 지원자 상태 변경", description = "모임 지원자의 지원 상태를 변경합니다.")
ResponseEntity<Void> updateApplyStatus(@PathVariable Integer meetingId, @RequestBody @Valid ApplyV2UpdateStatusBodyDto requestBody ,Principal principal);

@Operation(summary = "Meeting 썸네일 업로드용 Pre-Signed URL 발급", description = "Meeting 썸네일 업로드용 Pre-Signed URL 발급합니다.")
ResponseEntity<PreSignedUrlResponseDto> createPreSignedUrl(@PathParam("contentType") String contentType ,Principal principal);

@Operation(summary = "모임 지원자 목록 csv 파일 다운로드", description = "모임 지원자 목록 csv 파일 다운로드")
ResponseEntity<AppliesCsvFileUrlResponseDto> getAppliesCsvFileUrl(
@PathVariable Integer meetingId,
@PathParam("status") Integer status,
@PathParam("type") Integer type,
@PathParam("order") String order,
Principal principal);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,26 @@
import java.security.Principal;
import java.util.List;

import jakarta.websocket.server.PathParam;
import lombok.RequiredArgsConstructor;

import org.sopt.makers.crew.main.common.dto.TempResponseDto;
import org.sopt.makers.crew.main.common.util.UserUtil;
import org.sopt.makers.crew.main.external.s3.service.S3Service;
import org.sopt.makers.crew.main.meeting.v2.dto.query.MeetingGetAppliesQueryDto;
import org.sopt.makers.crew.main.meeting.v2.dto.query.MeetingV2GetAllMeetingByOrgUserQueryDto;
import org.sopt.makers.crew.main.meeting.v2.dto.query.MeetingV2GetAllMeetingQueryDto;
import org.sopt.makers.crew.main.meeting.v2.dto.request.ApplyV2UpdateStatusBodyDto;
import org.sopt.makers.crew.main.meeting.v2.dto.request.MeetingV2ApplyMeetingDto;
import org.sopt.makers.crew.main.meeting.v2.dto.request.MeetingV2CreateMeetingBodyDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.AppliesCsvFileUrlResponseDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.MeetingGetApplyListResponseDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.MeetingV2ApplyMeetingResponseDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.MeetingV2CreateMeetingResponseDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.MeetingV2GetAllMeetingByOrgUserDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.MeetingV2GetAllMeetingDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.MeetingV2GetMeetingBannerResponseDto;
import org.sopt.makers.crew.main.meeting.v2.dto.response.PreSignedUrlResponseDto;
import org.sopt.makers.crew.main.meeting.v2.service.MeetingV2Service;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand All @@ -43,6 +47,8 @@ public class MeetingV2Controller implements MeetingV2Api {

private final MeetingV2Service meetingV2Service;

private final S3Service s3Service;

@Override
@GetMapping("/org-user")
@ResponseStatus(HttpStatus.OK)
Expand Down Expand Up @@ -152,4 +158,26 @@ public ResponseEntity<Void> updateApplyStatus(

return ResponseEntity.ok().build();
}

@Override
@GetMapping("/presigned-url")
public ResponseEntity<PreSignedUrlResponseDto> createPreSignedUrl(
@PathParam("contentType") String contentType, Principal principal) {
PreSignedUrlResponseDto responseDto = s3Service.generatePreSignedUrl(contentType);

return ResponseEntity.ok(responseDto);
}

@Override
@GetMapping("/{meetingId}/list/csv")
public ResponseEntity<AppliesCsvFileUrlResponseDto> getAppliesCsvFileUrl(
@PathVariable Integer meetingId,
@PathParam("status") Integer status,
@PathParam("type") Integer type,
@PathParam("order") String order,
Principal principal) {


return null;
}
}
Loading
Loading