diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index bf33ef3c..1817677f 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -359,6 +359,30 @@ resource "aws_s3_bucket_public_access_block" "public-access" { restrict_public_buckets = false } +# S3 접근 정책 추가 +resource "aws_s3_bucket_policy" "bucket-policy" { + bucket = aws_s3_bucket.s3_1.id + + depends_on = [ + aws_s3_bucket_public_access_block.public-access + ] + + policy = <> uploadFile( - @ModelAttribute FileUploadRequestDto req, + @ModelAttribute @Valid FileUploadRequestDto req, @AuthenticationPrincipal CustomUserDetails user ) { FileUploadResponseDto res = fileService.uploadFile( @@ -37,8 +41,8 @@ public ResponseEntity> uploadFile( @GetMapping(value = "/read") public ResponseEntity> getFile( - @RequestParam("entityType") EntityType entityType, - @RequestParam("entityId") Long entityId + @RequestParam("entityType") @NotBlank(message = "entityType은 필수입니다.") EntityType entityType, + @RequestParam("entityId") @NotBlank(message = "entityId는 필수입니다.") Long entityId ) { FileReadResponseDto res = fileService.getFile(entityType, entityId); @@ -49,7 +53,7 @@ public ResponseEntity> getFile( @PutMapping(value = "/update", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> updateFile( - @ModelAttribute FileUpdateRequestDto req, + @ModelAttribute @Valid FileUpdateRequestDto req, @AuthenticationPrincipal CustomUserDetails user ) { fileService.updateFile( @@ -66,8 +70,8 @@ public ResponseEntity> updateFile( @DeleteMapping(value = "/delete") public ResponseEntity> deleteFile( - @RequestParam("entityType") EntityType entityType, - @RequestParam("entityId") Long entityId, + @RequestParam("entityType") @NotBlank(message = "entityType은 필수입니다.") EntityType entityType, + @RequestParam("entityId") @NotBlank(message = "entityId는 필수입니다.") Long entityId, @AuthenticationPrincipal CustomUserDetails user ) { fileService.deleteFile( diff --git a/src/main/java/com/back/domain/file/dto/FileUpdateRequestDto.java b/src/main/java/com/back/domain/file/dto/FileUpdateRequestDto.java index 2266f323..6b84d6af 100644 --- a/src/main/java/com/back/domain/file/dto/FileUpdateRequestDto.java +++ b/src/main/java/com/back/domain/file/dto/FileUpdateRequestDto.java @@ -1,12 +1,17 @@ package com.back.domain.file.dto; import com.back.domain.file.entity.EntityType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.springframework.web.multipart.MultipartFile; @Data public class FileUpdateRequestDto { + @NotNull(message = "파일 입력은 필수입니다.") private MultipartFile multipartFile; + private EntityType entityType; + private Long entityId; -} +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/file/dto/FileUploadRequestDto.java b/src/main/java/com/back/domain/file/dto/FileUploadRequestDto.java index 759a6750..4ef8d60f 100644 --- a/src/main/java/com/back/domain/file/dto/FileUploadRequestDto.java +++ b/src/main/java/com/back/domain/file/dto/FileUploadRequestDto.java @@ -1,13 +1,17 @@ package com.back.domain.file.dto; import com.back.domain.file.entity.EntityType; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; import org.springframework.web.multipart.MultipartFile; @Data public class FileUploadRequestDto { + @NotNull(message = "파일 입력은 필수입니다.") private MultipartFile multipartFile; + private EntityType entityType; + private Long entityId; } diff --git a/src/main/java/com/back/domain/file/dto/FileUploadResponseDto.java b/src/main/java/com/back/domain/file/dto/FileUploadResponseDto.java index bc5a7658..bee746c6 100644 --- a/src/main/java/com/back/domain/file/dto/FileUploadResponseDto.java +++ b/src/main/java/com/back/domain/file/dto/FileUploadResponseDto.java @@ -9,4 +9,4 @@ public class FileUploadResponseDto { public FileUploadResponseDto(String imageUrl) { this.imageUrl = imageUrl; } -} +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/file/entity/EntityType.java b/src/main/java/com/back/domain/file/entity/EntityType.java index 2a92c417..3c5a4f49 100644 --- a/src/main/java/com/back/domain/file/entity/EntityType.java +++ b/src/main/java/com/back/domain/file/entity/EntityType.java @@ -1,5 +1,5 @@ package com.back.domain.file.entity; public enum EntityType { - POST, COMMENT, CHAT -} + POST, COMMENT +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/file/service/FileService.java b/src/main/java/com/back/domain/file/service/FileService.java index dadac783..77f1b836 100644 --- a/src/main/java/com/back/domain/file/service/FileService.java +++ b/src/main/java/com/back/domain/file/service/FileService.java @@ -11,6 +11,7 @@ import com.back.domain.file.entity.FileAttachment; import com.back.domain.file.repository.AttachmentMappingRepository; import com.back.domain.file.repository.FileAttachmentRepository; +import com.back.domain.file.util.EntityValidator; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; import com.back.global.exception.CustomException; @@ -34,6 +35,7 @@ public class FileService { private final FileAttachmentRepository fileAttachmentRepository; private final UserRepository userRepository; private final AttachmentMappingRepository attachmentMappingRepository; + private final EntityValidator entityValidator; @Transactional public FileUploadResponseDto uploadFile( @@ -50,7 +52,7 @@ public FileUploadResponseDto uploadFile( // S3에 저장할 파일 이름 String storedFileName = createFileName(multipartFile.getOriginalFilename()); - // S3의 저장된 파일의 public URL + // S3의 저장된 파일의 PublicURL String filePath = s3Upload(storedFileName, multipartFile); // FileAttachment 정보 저장 @@ -74,13 +76,9 @@ public FileReadResponseDto getFile( EntityType entityType, Long entityId ) { - AttachmentMapping attachmentMapping = attachmentMappingRepository - .findByEntityTypeAndEntityId(entityType, entityId) - .orElseThrow(() -> - new CustomException(ErrorCode.ATTACHMENT_MAPPING_NOT_FOUND) - ); + FileAttachment fileAttachment = getFileAttachmentOrThrow(entityType, entityId); - String filePath = attachmentMapping.getFileAttachment().getFilePath(); + String filePath = fileAttachment.getFilePath(); return new FileReadResponseDto(filePath); } @@ -92,18 +90,11 @@ public void updateFile( Long entityId, Long userId ) { - AttachmentMapping attachmentMapping = attachmentMappingRepository - .findByEntityTypeAndEntityId(entityType, entityId) - .orElseThrow(() -> - new CustomException(ErrorCode.ATTACHMENT_MAPPING_NOT_FOUND) - ); + entityValidator.validate(entityType, entityId); - FileAttachment fileAttachment = attachmentMapping.getFileAttachment(); + FileAttachment fileAttachment = getFileAttachmentOrThrow(entityType, entityId); - // 파일 접근 권한 체크 - if (fileAttachment.getUser().getId() != userId) { - throw new CustomException(ErrorCode.FILE_ACCESS_DENIED); - } + checkAccessPermission(fileAttachment, userId); // 현재 저장된(삭제할) 파일 이름 String oldStoredName = fileAttachment.getStoredName(); @@ -113,7 +104,6 @@ public void updateFile( String filePath = s3Upload(newStoredName, multipartFile); - // S3에 기존에 저장된 파일 삭제 s3Delete(oldStoredName); // fileAttachment 정보 업데이트 @@ -122,6 +112,8 @@ public void updateFile( @Transactional public void deleteFile(EntityType entityType, Long entityId, Long userId) { + entityValidator.validate(entityType, entityId); + AttachmentMapping attachmentMapping = attachmentMappingRepository .findByEntityTypeAndEntityId(entityType, entityId) .orElseThrow(() -> @@ -130,19 +122,15 @@ public void deleteFile(EntityType entityType, Long entityId, Long userId) { FileAttachment fileAttachment = attachmentMapping.getFileAttachment(); - // 파일 접근 권한 체크 - if (fileAttachment.getUser().getId() != userId) { - throw new CustomException(ErrorCode.FILE_ACCESS_DENIED); - } + checkAccessPermission(fileAttachment, userId); - // S3 오브젝트 삭제 s3Delete(fileAttachment.getStoredName()); // fileAttachment 정보 삭제 fileAttachmentRepository.delete(fileAttachment); } - // S3에 파일을 업로드 하는 함수 + // S3 오브젝트 생성 private String s3Upload( String storedFileName, MultipartFile multipartFile @@ -173,13 +161,31 @@ private String s3Upload( return filePath; } - // S3 파일 삭제 함수 + // S3 오브젝트 삭제 private void s3Delete(String fileName) { amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName)); } - // 파일 이름을 난수화 하기 위한 함수 + // 파일 이름을 난수화 private String createFileName(String fileName) { return UUID.randomUUID().toString().concat(fileName); } -} + + // 파일 접근 권한 체크 + private void checkAccessPermission(FileAttachment fileAttachment, Long userId) { + if (fileAttachment.getUser().getId() != userId) { + throw new CustomException(ErrorCode.FILE_ACCESS_DENIED); + } + } + + // AttachmentMapping -> fileAttachment 추출 + private FileAttachment getFileAttachmentOrThrow(EntityType entityType, Long entityId) { + AttachmentMapping attachmentMapping = attachmentMappingRepository + .findByEntityTypeAndEntityId(entityType, entityId) + .orElseThrow(() -> + new CustomException(ErrorCode.ATTACHMENT_MAPPING_NOT_FOUND) + ); + + return attachmentMapping.getFileAttachment(); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/file/util/EntityValidator.java b/src/main/java/com/back/domain/file/util/EntityValidator.java new file mode 100644 index 00000000..7167bc1c --- /dev/null +++ b/src/main/java/com/back/domain/file/util/EntityValidator.java @@ -0,0 +1,31 @@ +package com.back.domain.file.util; + +import com.back.domain.board.comment.repository.CommentRepository; +import com.back.domain.board.post.repository.PostRepository; +import com.back.domain.file.entity.EntityType; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * EntityType, EntityId를 통해 매핑되는 데이터 존재 확인 + */ +@Component +@RequiredArgsConstructor +public class EntityValidator { + private final PostRepository postRepository; + private final CommentRepository commentRepository; + + public void validate(EntityType entityType, Long entityId) { + switch (entityType) { + case POST: + if(!postRepository.existsById(entityId)) throw new CustomException(ErrorCode.POST_NOT_FOUND); + break; + + case COMMENT: + if(!commentRepository.existsById(entityId)) throw new CustomException(ErrorCode.COMMENT_NOT_FOUND); + break; + } + } +} diff --git a/src/main/java/com/back/global/security/SecurityConfig.java b/src/main/java/com/back/global/security/SecurityConfig.java index c835aef6..fdd13498 100644 --- a/src/main/java/com/back/global/security/SecurityConfig.java +++ b/src/main/java/com/back/global/security/SecurityConfig.java @@ -88,7 +88,8 @@ public WebMvcConfigurer corsConfigurer() { public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins( - "http://localhost:3000" // Next.js 개발 서버 + "http://localhost:3000", // Catfe 프론트 개발 서버 + "https://www.catfe.com" // Catfe 프론트 운영 서버 ) .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") .allowedHeaders("*") diff --git a/src/test/java/com/back/domain/file/controller/FileControllerTest.java b/src/test/java/com/back/domain/file/controller/FileControllerTest.java new file mode 100644 index 00000000..874644ae --- /dev/null +++ b/src/test/java/com/back/domain/file/controller/FileControllerTest.java @@ -0,0 +1,136 @@ +package com.back.domain.file.controller; + +import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.repository.PostRepository; +import com.back.domain.file.config.S3MockConfig; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.fixture.TestJwtTokenProvider; +import io.findify.s3mock.S3Mock; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@Import(S3MockConfig.class) +@ActiveProfiles("test") +@AutoConfigureMockMvc +@Transactional +class FileControllerTest { + @Autowired + private MockMvc mvc; + + @Autowired + private S3Mock s3Mock; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private PostRepository postRepository; + + @Autowired + private TestJwtTokenProvider testJwtTokenProvider; + + private String generateAccessToken(User user) { + return testJwtTokenProvider.createAccessToken( + user.getId(), + user.getUsername(), + user.getRole().name() + ); + } + + @AfterEach + public void tearDown() { + s3Mock.stop(); + } + + @Test + @DisplayName("파일 업로드 성공") + void uploadFile_success() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 1000)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + Post post = new Post(user, "첫 글", "내용", null); + postRepository.save(post); + + MockMultipartFile multipartFile = new MockMultipartFile( + "multipartFile", + "test.png", + "image/png", + "test".getBytes() + ); + + // when + ResultActions resultActions = mvc.perform( + multipart("/api/file/upload") // 👈 post() 대신 multipart() 사용 + .file(multipartFile) // 파일 필드 + .param("entityType", "POST") // DTO 필드 매핑 + .param("entityId", post.getId().toString()) + .header("Authorization", "Bearer " + accessToken) + .characterEncoding("UTF-8") + ); + + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("파일 업로드 성공")) + .andDo(print()); + } + + @Test + @DisplayName("파일 업로드 실패 - 파일이 없는 경우") + void uploadFile_fail_noFile() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 1000)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + Post post = new Post(user, "첫 글", "내용", null); + postRepository.save(post); + + // when + ResultActions resultActions = mvc.perform( + multipart("/api/file/upload") // 👈 post() 대신 multipart() 사용 + .param("entityType", "POST") // DTO 필드 매핑 + .param("entityId", post.getId().toString()) + .header("Authorization", "Bearer " + accessToken) + .characterEncoding("UTF-8") + ); + + // then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON_400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) + .andDo(print()); + } +} \ No newline at end of file