diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b751695..ec3243f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -56,6 +56,14 @@ jobs: DEV_SECRET_DIR_FILE_NAME: application-mail.yml run: echo $DEV_SECRET | base64 --decode >> $DEV_SECRET_DIR/$DEV_SECRET_DIR_FILE_NAME + # application-s3.yml + - name: Copy s3 secret + env: + DEV_SECRET: ${{ secrets.APPLICATION_S3_YML }} + DEV_SECRET_DIR: src/main/resources + DEV_SECRET_DIR_FILE_NAME: application-s3.yml + run: echo $DEV_SECRET | base64 --decode >> $DEV_SECRET_DIR/$DEV_SECRET_DIR_FILE_NAME + # ./gradlew 권한 설정 - name: ./gradlew 권한 설정 run: chmod +x ./gradlew diff --git a/.gitignore b/.gitignore index 495e681..371c58e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ application-dev.yml application-prod.yml application-jwt.yml application-mail.yml +application-s3.yml diff --git a/build.gradle b/build.gradle index e4ef8d6..9e73624 100644 --- a/build.gradle +++ b/build.gradle @@ -41,6 +41,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-mail' //swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + //s3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } tasks.named('test') { diff --git a/src/main/java/com/backend/config/S3Config.java b/src/main/java/com/backend/config/S3Config.java new file mode 100644 index 0000000..10526ed --- /dev/null +++ b/src/main/java/com/backend/config/S3Config.java @@ -0,0 +1,31 @@ +package com.backend.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@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 AmazonS3Client amazonS3Client() { + BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey); + return (AmazonS3Client) AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/backend/config/SecurityConfig.java b/src/main/java/com/backend/config/SecurityConfig.java index 12a0c45..b1f925e 100644 --- a/src/main/java/com/backend/config/SecurityConfig.java +++ b/src/main/java/com/backend/config/SecurityConfig.java @@ -61,6 +61,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(mvcMatcherBuilder.pattern("/v3/api-docs/**")).permitAll() .requestMatchers(mvcMatcherBuilder.pattern("/api/**")).permitAll() .requestMatchers(mvcMatcherBuilder.pattern("/mail/**")).permitAll() + .requestMatchers(mvcMatcherBuilder.pattern("/s3/create")).permitAll() .anyRequest().authenticated()) .exceptionHandling() .authenticationEntryPoint(entryPoint); diff --git a/src/main/java/com/backend/domain/auth/dto/request/JoinRequestDto.java b/src/main/java/com/backend/domain/auth/dto/request/JoinRequestDto.java index fca246c..78bdaed 100644 --- a/src/main/java/com/backend/domain/auth/dto/request/JoinRequestDto.java +++ b/src/main/java/com/backend/domain/auth/dto/request/JoinRequestDto.java @@ -6,13 +6,14 @@ import org.springframework.security.crypto.password.PasswordEncoder; public record JoinRequestDto(@NotEmpty String password, @NotEmpty String email, @NotEmpty String type, - @NotEmpty String typeName) { + @NotEmpty String typeName, @NotEmpty String proofImageUrl) { public User toEntity(PasswordEncoder passwordEncoder) { return User.builder() .email(email) .password(passwordEncoder.encode(this.password)) .type(GroupType.create(type)) .typeName(typeName) + .proofImageUrl(proofImageUrl) .build(); } } \ No newline at end of file diff --git a/src/main/java/com/backend/domain/s3/controller/S3Controller.java b/src/main/java/com/backend/domain/s3/controller/S3Controller.java new file mode 100644 index 0000000..7edef26 --- /dev/null +++ b/src/main/java/com/backend/domain/s3/controller/S3Controller.java @@ -0,0 +1,46 @@ +package com.backend.domain.s3.controller; + +import com.backend.common.dto.ResponseDto; +import com.backend.domain.auth.dto.Login; +import com.backend.domain.auth.dto.LoginUser; +import com.backend.domain.s3.service.S3Service; +import com.backend.error.dto.ErrorResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/s3") +public class S3Controller { + + private final S3Service s3Service; + + @Operation(summary = "이미지 추가", description = "s3에 이미지를 추가합니다. 사용자가 업로드한 이미지 파일이 필요합니다.", + responses = { + @ApiResponse(responseCode = "204", description = "이미지 생성 성공, 이미지 파일 이름을 반환합니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping(value = "/create") + public ResponseEntity uploadImage(@RequestPart MultipartFile file) { + return ResponseDto.created(s3Service.uploadImage((file))); + } + + @Operation(summary = "이미지 추가", description = "해당하는 파일 이름의 이미지를 s3에서 삭제합니다. " + + "사용자의 AccessToken과 지우려는 파일 이름이 필요합니다.", + responses = { + @ApiResponse(responseCode = "200", description = "이미지 삭제 성공 메세지를 반환합니다."), + @ApiResponse(responseCode = "401", description = "토큰이 올바르지 않을 때 예외가 발생합니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @DeleteMapping("/delete") + public ResponseEntity deleteImage(@Login LoginUser loginUser, @RequestPart String fileName) { + s3Service.deleteImage(loginUser, fileName); + return ResponseDto.ok("이미지 삭제 성공"); + } +} \ No newline at end of file diff --git a/src/main/java/com/backend/domain/s3/service/S3Service.java b/src/main/java/com/backend/domain/s3/service/S3Service.java new file mode 100644 index 0000000..a2fb40f --- /dev/null +++ b/src/main/java/com/backend/domain/s3/service/S3Service.java @@ -0,0 +1,73 @@ +package com.backend.domain.s3.service; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.backend.domain.auth.dto.LoginUser; +import com.backend.domain.user.entity.User; +import com.backend.domain.user.repository.UserRepository; +import com.backend.error.ErrorCode; +import com.backend.error.exception.custom.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3Service { + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + private final AmazonS3 amazonS3; + private final UserRepository userRepository; + + public String uploadImage(MultipartFile file) { + + log.info("multipartFile: {}", file); + + String fileName = createFileName(file.getOriginalFilename()); + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentLength(file.getSize()); + objectMetadata.setContentType(file.getContentType()); + + try (InputStream inputStream = file.getInputStream()) { + amazonS3.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata) + .withCannedAcl(CannedAccessControlList.PublicRead)); + } catch (IOException e) { + throw new BusinessException(ErrorCode.IMAGE_UPLOAD_FAIL); + } + //return amazonS3.getUrl(bucket, fileName).toString(); + return fileName; + } + + public void deleteImage(LoginUser loginUser, String fileName) { + User user = userRepository.findByEmail(loginUser.getEmail()) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + user.deleteProofImage(); + amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName)); + } + + private String createFileName(String fileName) { + return UUID.randomUUID().toString().concat(getFileExtension(fileName)); + } + + private String getFileExtension(String fileName) { + try { + return fileName.substring(fileName.lastIndexOf(".")); + } catch (StringIndexOutOfBoundsException e) { + throw new BusinessException(ErrorCode.INVALID_FILE); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/backend/domain/user/entity/User.java b/src/main/java/com/backend/domain/user/entity/User.java index ec49420..0e63fbb 100644 --- a/src/main/java/com/backend/domain/user/entity/User.java +++ b/src/main/java/com/backend/domain/user/entity/User.java @@ -27,15 +27,16 @@ public class User { private String refreshToken; - //todo. 대표 이미지 필드 추가 + private String proofImageUrl; @Builder - public User(String email, String password, GroupType type, String typeName, String refreshToken) { + public User(String email, String password, GroupType type, String typeName, String refreshToken, String proofImageUrl) { this.email = email; this.password = password; this.type = type; this.typeName = typeName; this.refreshToken = refreshToken; + this.proofImageUrl = proofImageUrl; } public void updateRefreshToken(String refreshToken) { @@ -45,4 +46,8 @@ public void updateRefreshToken(String refreshToken) { public void invalidateRefreshToken() { this.refreshToken = null; } + + public void deleteProofImage() { + this.proofImageUrl = null; + } } \ No newline at end of file diff --git a/src/main/java/com/backend/error/ErrorCode.java b/src/main/java/com/backend/error/ErrorCode.java index c2253d6..5ee15b5 100644 --- a/src/main/java/com/backend/error/ErrorCode.java +++ b/src/main/java/com/backend/error/ErrorCode.java @@ -15,7 +15,9 @@ public enum ErrorCode { ALREADY_EXIST_EMAIL(BAD_REQUEST, "이미 존재하는 이메일입니다."), INVALID_TOKEN(UNAUTHORIZED, "잘못된 토큰입니다."), INVALID_GROUP_TYPE(BAD_REQUEST, "잘못된 그룹 종류입니다."), - INVALID_PASSWORD(BAD_REQUEST, "잘못된 비밀번호입니다."); + INVALID_PASSWORD(BAD_REQUEST, "잘못된 비밀번호입니다."), + IMAGE_UPLOAD_FAIL(BAD_REQUEST, "이미지 업로드에 실패했습니다."), + INVALID_FILE(BAD_REQUEST, "잘못된 파일 형식입니다."); private final int code; private final String message; @@ -24,4 +26,4 @@ public enum ErrorCode { this.code = code.value(); this.message = message; } -} +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5b3e4b8..5bc9f8e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,4 +9,5 @@ spring: - prod include: - jwt - - mail \ No newline at end of file + - mail + - s3 \ No newline at end of file