diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 57018759..ce246fc4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,9 +4,11 @@ on: push: branches: - main + - test pull_request: branches: - main + - test workflow_dispatch: inputs: @@ -20,6 +22,7 @@ on: env: S3_BUCKET_NAME: wingle-ci-bucket + TEST_S3_BUCKET_NAME: wingle-test-ci-bucket CODE_DEPLOY_APPLICATION_NAME: wingle-codedeploy-app CODE_DEPLOY_DEPLOYMENT_GROUP_NAME: wingle-codedeploy-group @@ -46,13 +49,24 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - - name: make main application.yml + - name: πŸš€ PROD - make main application.yml + if: github.ref == 'refs/heads/main' run: | sudo mkdir -p ./wingle/src/main/resources sudo chmod 777 ./wingle/src/main/resources cd ./wingle/src/main/resources touch ./application.yml echo "${{ secrets.MAIN_YML }}" > ./application.yml + + - name: πŸ‘€ TEST - make stage application.yml + if: github.ref == 'refs/heads/test' + run: | + sudo mkdir -p ./wingle/src/main/resources + sudo chmod 777 ./wingle/src/main/resources + cd ./wingle/src/main/resources + touch ./application.yml + echo "${{ secrets.STAGE_YML }}" > ./application.yml + # 파일 μ—†μœΌλ©΄ λΉŒλ“œ μ—λŸ¬ - uses: actions/upload-artifact@v3 with: @@ -104,20 +118,20 @@ jobs: shell: bash - name: Make zip file - if: contains(github.ref, 'main') +# if: contains(github.ref, 'main') run: zip -r ./$GITHUB_SHA.zip . shell: bash - - name: Configure AWS credentials - if: contains(github.ref, 'main') + - name: πŸš€ PROD - Configure AWS credentials + if: github.ref == 'refs/heads/main' uses: aws-actions/configure-aws-credentials@v1 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }} - - name: Upload to AWS S3 - if: contains(github.ref, 'main') + - name: πŸš€ PROD - Upload to AWS S3 + if: github.ref == 'refs/heads/main' run: | cd ./wingle aws deploy push \ @@ -127,11 +141,40 @@ jobs: --source . # S3 버킷에 μžˆλŠ” νŒŒμΌμ„ λŒ€μƒμœΌλ‘œ CodeDeploy μ‹€ν–‰ - - name: Deploy to AWS EC2 from S3 - if: contains(github.ref, 'main') + - name: πŸš€ PROD - Deploy to AWS EC2 from S3 + if: github.ref == 'refs/heads/main' run: | aws deploy create-deployment \ --application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \ --deployment-config-name CodeDeployDefault.AllAtOnce \ --deployment-group-name ${{ env.CODE_DEPLOY_DEPLOYMENT_GROUP_NAME }} \ --s3-location bucket=$S3_BUCKET_NAME,key=build/$GITHUB_SHA.zip,bundleType=zip + + + - name: πŸ‘€ TEST - Configure AWS credentials + if: github.ref == 'refs/heads/test' + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.TEST_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.TEST_AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: πŸ‘€ TEST - Upload to AWS S3 + if: github.ref == 'refs/heads/test' + run: | + cd ./wingle + aws deploy push \ + --application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \ + --ignore-hidden-files \ + --s3-location s3://$TEST_S3_BUCKET_NAME/build/$GITHUB_SHA.zip \ + --source . + + # S3 버킷에 μžˆλŠ” νŒŒμΌμ„ λŒ€μƒμœΌλ‘œ CodeDeploy μ‹€ν–‰ + - name: πŸ‘€ TEST - Deploy to AWS EC2 from S3 + if: github.ref == 'refs/heads/test' + run: | + aws deploy create-deployment \ + --application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \ + --deployment-config-name CodeDeployDefault.AllAtOnce \ + --deployment-group-name ${{ env.CODE_DEPLOY_DEPLOYMENT_GROUP_NAME }} \ + --s3-location bucket=$TEST_S3_BUCKET_NAME,key=build/$GITHUB_SHA.zip,bundleType=zip diff --git a/wingle/src/main/java/kr/co/wingle/common/util/AES256Util.java b/wingle/src/main/java/kr/co/wingle/common/util/AES256Util.java index 068aca1b..7a5acf28 100644 --- a/wingle/src/main/java/kr/co/wingle/common/util/AES256Util.java +++ b/wingle/src/main/java/kr/co/wingle/common/util/AES256Util.java @@ -1,13 +1,16 @@ package kr.co.wingle.common.util; -import java.io.UnsupportedEncodingException; -import java.security.GeneralSecurityException; -import java.security.Key; - import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; +import org.apache.commons.codec.DecoderException; +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.codec.binary.Hex; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import kr.co.wingle.common.constants.ErrorCode; @@ -15,45 +18,62 @@ @Component public class AES256Util { - //initial vector μ„€μ • - private static String iv = "0000000000000001"; - private static Key keySpec; - - public AES256Util() throws UnsupportedEncodingException { - iv = iv.substring(0, 16); - byte[] keyBytes = new byte[16]; - byte[] b = iv.getBytes("UTF-8"); - int len = b.length; - if (len > keyBytes.length) { - len = keyBytes.length; - } - System.arraycopy(b, 0, keyBytes, 0, len); - SecretKeySpec _keySpec = new SecretKeySpec(keyBytes, "AES"); - keySpec = _keySpec; + + private static String KEY; + private static byte[] SALT; + private static String IV; + + @Value("${aes256.key}") + public void setKEY(String KEY) { + AES256Util.KEY = KEY; + } + + @Value("${aes256.salt}") + public void setSALT(String SALT) throws DecoderException { + AES256Util.SALT = Hex.decodeHex(SALT.toCharArray()); + } + + @Value("${aes256.iv}") + public void setIV(String IV) { + AES256Util.IV = IV; } - //μ•”ν˜Έν™” public static String encrypt(String str) { try { - Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding"); - c.init(Cipher.ENCRYPT_MODE, keySpec, new IvParameterSpec((iv.getBytes()))); - byte[] encrypted = c.doFinal(str.getBytes("UTF-8")); - // String enStr = new String(Base64.encodeBase64(encrypted)); - return new java.math.BigInteger(encrypted).toString(16); - } catch (GeneralSecurityException | UnsupportedEncodingException e) { + SecretKey key = generateKey(KEY); + byte[] encrypted = doFinal(Cipher.ENCRYPT_MODE, key, IV, str.getBytes("UTF-8")); + return encodeHex(encrypted); + } catch (Exception e) { + throw new InternalServerErrorException(ErrorCode.ENCRYPT_FAIL); + } + } + + public static String encrypt(String str, String salt) { + try { + SecretKey key = generateKey(KEY, salt); + byte[] encrypted = doFinal(Cipher.ENCRYPT_MODE, key, IV, str.getBytes("UTF-8")); + return encodeHex(encrypted); + } catch (Exception e) { throw new InternalServerErrorException(ErrorCode.ENCRYPT_FAIL); } } - //λ³΅ν˜Έν™” public static String decrypt(String str) { try { - Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding"); - c.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(iv.getBytes())); - // byte[] byteStr = Base64.decodeBase64(str.getBytes()); - byte[] byteStr = new java.math.BigInteger(str, 16).toByteArray(); - return new String(c.doFinal(byteStr), "UTF-8"); - } catch (GeneralSecurityException | UnsupportedEncodingException e) { + SecretKey key = generateKey(KEY); + byte[] decrypted = doFinal(Cipher.DECRYPT_MODE, key, IV, decodeBase64(str)); + return new String(decrypted, "UTF-8"); + } catch (Exception e) { + throw new InternalServerErrorException(ErrorCode.DECRYPT_FAIL); + } + } + + public static String decrypt(String str, String salt) { + try { + SecretKey key = generateKey(KEY, salt); + byte[] decrypted = doFinal(Cipher.DECRYPT_MODE, key, IV, decodeBase64(str)); + return new String(decrypted, "UTF-8"); + } catch (Exception e) { throw new InternalServerErrorException(ErrorCode.DECRYPT_FAIL); } } @@ -61,4 +81,48 @@ public static String decrypt(String str) { public static Long userIdDecrypt(String userId) { return Long.parseLong(decrypt(userId)); } + + private static byte[] doFinal(int encryptMode, SecretKey key, String iv, byte[] bytes) throws Exception { + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(encryptMode, key, new IvParameterSpec(decodeHex(iv))); + return cipher.doFinal(bytes); + } + + private static SecretKey generateKey(String passPhrase) throws Exception { + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + + // generate key with salt + PBEKeySpec keySpec = new PBEKeySpec(passPhrase.toCharArray(), SALT, 3000, 256); + SecretKey key = new SecretKeySpec(factory.generateSecret(keySpec).getEncoded(), "AES"); + + return key; + } + + private static SecretKey generateKey(String passPhrase, String salt) throws Exception { + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + + // generate custom salt + PBEKeySpec saltSpec = new PBEKeySpec(salt.toCharArray(), SALT, 3000, 128); + SecretKey saltKey = new SecretKeySpec(factory.generateSecret(saltSpec).getEncoded(), "AES"); + + // generate key with custom salt + PBEKeySpec keySpec = new PBEKeySpec(passPhrase.toCharArray(), saltKey.toString().getBytes(), 3000, 256); + SecretKey key = new SecretKeySpec(factory.generateSecret(keySpec).getEncoded(), "AES"); + + return key; + } + + private static String encodeHex(byte[] bytes) { + return Hex.encodeHexString(bytes); + } + + private static byte[] decodeHex(String str) throws Exception { + return Hex.decodeHex(str.toCharArray()); + } + + private static byte[] decodeBase64(String str) { + byte[] decodeByte = Base64.decodeBase64(str); + return Base64.decodeBase64(decodeByte); + } + } diff --git a/wingle/src/main/java/kr/co/wingle/common/util/S3Util.java b/wingle/src/main/java/kr/co/wingle/common/util/S3Util.java index 9d9658a0..a41eae74 100644 --- a/wingle/src/main/java/kr/co/wingle/common/util/S3Util.java +++ b/wingle/src/main/java/kr/co/wingle/common/util/S3Util.java @@ -66,8 +66,13 @@ public String profileImageUpload(MultipartFile file) { } } - public String articleImageUpload(MultipartFile file) throws IOException { - return upload(file, "article"); + public String articleImageUpload(MultipartFile file) { + try { + return upload(file, "article"); + } catch (IOException e) { + log.warn(e.getMessage()); + throw new InternalServerErrorException(ErrorCode.FILE_UPLOAD_FAIL); + } } public void delete(String url) { diff --git a/wingle/src/main/java/kr/co/wingle/community/article/ArticleController.java b/wingle/src/main/java/kr/co/wingle/community/article/ArticleController.java index 4b254ea2..a56ae88a 100644 --- a/wingle/src/main/java/kr/co/wingle/community/article/ArticleController.java +++ b/wingle/src/main/java/kr/co/wingle/community/article/ArticleController.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -63,4 +64,11 @@ public ApiResponse delete(@PathVariable String forumId, articleService.delete(StringUtil.StringToLong(forumId), StringUtil.StringToLong(articleId))); } + @PutMapping("/{forumId}/articles/{articleId}") + public ApiResponse editArticle(@PathVariable String forumId, + @PathVariable String articleId, @ModelAttribute @Valid ArticleEditRequestDto articleEditRequestDto) { + return ApiResponse.success(SuccessCode.GET_SUCCESS, + articleService.editArticle(StringUtil.StringToLong(forumId), StringUtil.StringToLong(articleId), + articleEditRequestDto)); + } } diff --git a/wingle/src/main/java/kr/co/wingle/community/article/ArticleEditRequestDto.java b/wingle/src/main/java/kr/co/wingle/community/article/ArticleEditRequestDto.java new file mode 100644 index 00000000..1363cd0c --- /dev/null +++ b/wingle/src/main/java/kr/co/wingle/community/article/ArticleEditRequestDto.java @@ -0,0 +1,23 @@ +package kr.co.wingle.community.article; + +import java.util.List; + +import org.springframework.web.multipart.MultipartFile; + +import kr.co.wingle.common.validator.LengthWithoutCR; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class ArticleEditRequestDto { + + @LengthWithoutCR(min = 1, max = 3000, message = "λ‚΄μš©μ€ 1자 이상 3000자 μ΄ν•˜λ§Œ κ°€λŠ₯ν•©λ‹ˆλ‹€.") + private String content; + + private List originImages; + private List newImages; + +} diff --git a/wingle/src/main/java/kr/co/wingle/community/article/ArticleImage.java b/wingle/src/main/java/kr/co/wingle/community/article/ArticleImage.java new file mode 100644 index 00000000..3c008c97 --- /dev/null +++ b/wingle/src/main/java/kr/co/wingle/community/article/ArticleImage.java @@ -0,0 +1,39 @@ +package kr.co.wingle.community.article; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.ManyToOne; + +import org.springframework.util.Assert; + +import kr.co.wingle.common.entity.BaseEntity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ArticleImage extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + private Article article; + + @Column(nullable = false) + private String imageUrl; + + @Column(nullable = false) + private int orderNumber; + + @Builder + ArticleImage(Article article, String imageUrl, int orderNumber) { + Assert.notNull(article, "article must not be null"); + Assert.notNull(imageUrl, "imageUrl must not be null"); + + this.article = article; + this.imageUrl = imageUrl; + this.orderNumber = orderNumber; + } +} diff --git a/wingle/src/main/java/kr/co/wingle/community/article/ArticleImageRepository.java b/wingle/src/main/java/kr/co/wingle/community/article/ArticleImageRepository.java new file mode 100644 index 00000000..2f27be53 --- /dev/null +++ b/wingle/src/main/java/kr/co/wingle/community/article/ArticleImageRepository.java @@ -0,0 +1,12 @@ +package kr.co.wingle.community.article; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ArticleImageRepository extends JpaRepository { + + List getArticleImagesByArticleIdAndIsDeletedOrderByOrderNumber(Long articleId, boolean isDeleted); + + List findAllByArticleId(Long articleId); +} diff --git a/wingle/src/main/java/kr/co/wingle/community/article/ArticleMapper.java b/wingle/src/main/java/kr/co/wingle/community/article/ArticleMapper.java index 76c2af9f..ed698aa8 100644 --- a/wingle/src/main/java/kr/co/wingle/community/article/ArticleMapper.java +++ b/wingle/src/main/java/kr/co/wingle/community/article/ArticleMapper.java @@ -38,7 +38,8 @@ public ArticleResponseDto toResponseDto(Article article, List images) { articleResponseDto.images(new ArrayList(list)); } articleResponseDto.isMine(processedPersonalInformation.isMine()); - articleResponseDto.userId(AES256Util.encrypt(processedPersonalInformation.getProcessedMemberId().toString())); + articleResponseDto.userId( + AES256Util.encrypt(article.getMember().getId().toString(), article.getId().toString())); articleResponseDto.userImage(profileService.getProfileByMemberId(article.getMember().getId()).getImageUrl()); articleResponseDto.userNation(profileService.getProfileByMemberId(article.getMember().getId()).getNation()); articleResponseDto.userSchoolName(processedPersonalInformation.getSchoolName()); diff --git a/wingle/src/main/java/kr/co/wingle/community/article/ArticleService.java b/wingle/src/main/java/kr/co/wingle/community/article/ArticleService.java index 6d9881f4..5dc5cda1 100644 --- a/wingle/src/main/java/kr/co/wingle/community/article/ArticleService.java +++ b/wingle/src/main/java/kr/co/wingle/community/article/ArticleService.java @@ -13,6 +13,7 @@ import kr.co.wingle.common.constants.ErrorCode; import kr.co.wingle.common.exception.ForbiddenException; import kr.co.wingle.common.exception.NotFoundException; +import kr.co.wingle.common.util.S3Util; import kr.co.wingle.community.forum.Forum; import kr.co.wingle.community.forum.ForumCode; import kr.co.wingle.community.forum.ForumService; @@ -25,9 +26,11 @@ @Service public class ArticleService extends WritingService { private final ArticleRepository articleRepository; + private final ArticleImageRepository articleImageRepository; private final ForumService forumService; private final AuthService authService; private final ArticleMapper articleMapper; + private final S3Util s3Util; @Transactional public ArticleResponseDto create(ArticleRequestDto request) { @@ -47,18 +50,48 @@ public ArticleResponseDto create(ArticleRequestDto request) { articleRepository.save(article); + ArrayList imageUrlList = new ArrayList<>(); + for (int i = 0; i < request.getImages().size(); i++) { + String imageUrl = s3Util.articleImageUpload(request.getImages().get(i)); + imageUrlList.add(imageUrl); + articleImageRepository.save(new ArticleImage(article, imageUrl, i + 1)); + } + // TODO: Redis μ΅œμ‹ λͺ©λ‘μ— 등둝 - // TODO: new ArrayList 뢀뢄을 s3μ—μ„œ 받은 이미지 경둜둜 λ³€κ²½ - return articleMapper.toResponseDto(article, new ArrayList()); + return articleMapper.toResponseDto(article, imageUrlList); + } + + public ArticleResponseDto editArticle(Long forumId, Long articleId, ArticleEditRequestDto request) { + Article article = getArticleById(articleId); + isValidForum(article, forumId); + + article.setContent(request.getContent()); + + List allByArticle = articleImageRepository.findAllByArticleId(articleId); + articleImageRepository.deleteAll(allByArticle); + + ArrayList imageUrlList = (ArrayList)request.getOriginImages(); + imageUrlList.addAll(request.getNewImages().stream().map(image -> s3Util.articleImageUpload(image)) + .collect(Collectors.toList())); + + for (int i = 0; i < imageUrlList.size(); i++) { + articleImageRepository.save(new ArticleImage(article, imageUrlList.get(i), i + 1)); + } + + return articleMapper.toResponseDto(article, imageUrlList); } @Transactional(readOnly = true) public ArticleResponseDto getOne(Long forumId, Long articleId) { Article article = getArticleById(articleId); isValidForum(article, forumId); - // TODO: new ArrayList 뢀뢄을 s3μ—μ„œ 받은 이미지 경둜둜 λ³€κ²½ - return articleMapper.toResponseDto(article, new ArrayList()); + + List imageUrlList = articleImageRepository.getArticleImagesByArticleIdAndIsDeletedOrderByOrderNumber( + article.getId(), false) + .stream().map(articleImage -> articleImage.getImageUrl()).collect(Collectors.toList()); + + return articleMapper.toResponseDto(article, imageUrlList); } @Transactional(readOnly = true) @@ -71,9 +104,11 @@ public List getList(Long forumId, int page, int size, boolea } else { pages = articleRepository.findByForumIdAndIsDeleted(forumId, false, pageable); } - // TODO: new ArrayList 뢀뢄을 s3μ—μ„œ 받은 이미지 경둜둜 λ³€κ²½ + return pages.stream() - .map(x -> articleMapper.toResponseDto(x, new ArrayList())) + .map(x -> articleMapper.toResponseDto(x, + articleImageRepository.getArticleImagesByArticleIdAndIsDeletedOrderByOrderNumber(x.getId(), false) + .stream().map(articleImage -> articleImage.getImageUrl()).collect(Collectors.toList()))) .collect( Collectors.toList()); } @@ -84,6 +119,8 @@ public Long delete(Long forumId, Long articleId) { Article article = getArticleById(articleId); if (isValidMember(article, member) && isExist(article) && isValidForum(article, forumId)) { + articleImageRepository.getArticleImagesByArticleIdAndIsDeletedOrderByOrderNumber(articleId, false) + .forEach(articleImage -> articleImage.softDelete()); article.softDelete(); } return article.getId(); diff --git a/wingle/src/main/java/kr/co/wingle/community/comment/CommentMapper.java b/wingle/src/main/java/kr/co/wingle/community/comment/CommentMapper.java index e6336eeb..1c774557 100644 --- a/wingle/src/main/java/kr/co/wingle/community/comment/CommentMapper.java +++ b/wingle/src/main/java/kr/co/wingle/community/comment/CommentMapper.java @@ -22,7 +22,8 @@ public CommentResponseDto toResponseDto(Comment comment) { CommentResponseDto.CommentResponseDtoBuilder commentResponseDto = CommentResponseDto.builder(); commentResponseDto.id(comment.getId()); - commentResponseDto.userId(AES256Util.encrypt(processedPersonalInformation.getProcessedMemberId().toString())); + commentResponseDto.userId( + AES256Util.encrypt(comment.getMember().getId().toString(), comment.getArticle().getId().toString())); commentResponseDto.userNickname(processedPersonalInformation.getNickname()); commentResponseDto.userImage(profileService.getProfileByMemberId(comment.getMember().getId()).getImageUrl()); commentResponseDto.userNation(profileService.getProfileByMemberId(comment.getMember().getId()).getNation());