diff --git a/build.gradle b/build.gradle index 4e60d56..b728371 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,11 @@ dependencies { implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' implementation 'org.commonmark:commonmark:0.21.0' + implementation 'org.commonmark:commonmark-ext-gfm-tables:0.20.0' // 테이블 관련 추가 설정 + implementation 'org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0' // 취소선 관련 추가 설정 + implementation 'com.atlassian.commonmark:commonmark-ext-task-list-items:0.15.0' // task 관련 추가 설정 + + implementation 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20211018.2' implementation 'org.springframework.boot:spring-boot-starter-mail' diff --git a/src/main/java/com/ll/spring_additional/base/security/SecurityConfig.java b/src/main/java/com/ll/spring_additional/base/security/SecurityConfig.java index 1f2412f..4fe807f 100644 --- a/src/main/java/com/ll/spring_additional/base/security/SecurityConfig.java +++ b/src/main/java/com/ll/spring_additional/base/security/SecurityConfig.java @@ -1,5 +1,16 @@ package com.ll.spring_additional.base.security; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.commonmark.Extension; +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension; +import org.commonmark.ext.gfm.tables.TablesExtension; + +import org.commonmark.ext.task.list.items.TaskListItemsExtension; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -8,7 +19,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @Configuration // 스프링 환경설정 파일 @EnableWebSecurity // 모든 요청 URL이 스프링 시큐리티의 제어를 받도록 -> 내부적으로 시큐리티 필터체인 동작 @@ -42,4 +52,28 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + // 마크다운 렌더링 렌더러 및 파서 빈 등록 + @Bean + HtmlRenderer htmlRenderer(List extensions) { + return HtmlRenderer.builder() + .extensions(extensions) + .build(); + } + + @Bean + Parser parser(List extensions) { + return Parser.builder() + .extensions(extensions) + .build(); + } + + @Bean + List markdownExtensions() { + return Arrays.asList( + TablesExtension.create(), + StrikethroughExtension.create(), + TaskListItemsExtension.create() + ); + } } \ No newline at end of file diff --git a/src/main/java/com/ll/spring_additional/boundedContext/answer/entity/Answer.java b/src/main/java/com/ll/spring_additional/boundedContext/answer/entity/Answer.java index 4f8946b..2de6bc6 100644 --- a/src/main/java/com/ll/spring_additional/boundedContext/answer/entity/Answer.java +++ b/src/main/java/com/ll/spring_additional/boundedContext/answer/entity/Answer.java @@ -6,6 +6,8 @@ import java.util.List; import java.util.Set; +import org.hibernate.annotations.LazyCollection; +import org.hibernate.annotations.LazyCollectionOption; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -66,5 +68,6 @@ public void prePersist() { @OneToMany(mappedBy = "answer", cascade = {CascadeType.REMOVE}) @ToString.Exclude + @LazyCollection(LazyCollectionOption.EXTRA) // commentList.size(); 함수가 실행될 때 SELECT COUNT 실행 private List comments = new ArrayList<>(); } \ No newline at end of file diff --git a/src/main/java/com/ll/spring_additional/boundedContext/question/entity/Question.java b/src/main/java/com/ll/spring_additional/boundedContext/question/entity/Question.java index 835d350..203dd45 100644 --- a/src/main/java/com/ll/spring_additional/boundedContext/question/entity/Question.java +++ b/src/main/java/com/ll/spring_additional/boundedContext/question/entity/Question.java @@ -111,6 +111,7 @@ public String getCategoryAsString() { @OneToMany(mappedBy = "question", cascade = {CascadeType.REMOVE}) @ToString.Exclude + @LazyCollection(LazyCollectionOption.EXTRA) // commentList.size(); 함수가 실행될 때 SELECT COUNT 실행 private List comments = new ArrayList<>(); } diff --git a/src/main/java/com/ll/spring_additional/standard/util/CommonUtil.java b/src/main/java/com/ll/spring_additional/standard/util/CommonUtil.java index 7b36f0e..13a5b05 100644 --- a/src/main/java/com/ll/spring_additional/standard/util/CommonUtil.java +++ b/src/main/java/com/ll/spring_additional/standard/util/CommonUtil.java @@ -3,14 +3,37 @@ import org.commonmark.node.Node; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; +import org.owasp.html.HtmlPolicyBuilder; +import org.owasp.html.PolicyFactory; import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; + @Component +@RequiredArgsConstructor public class CommonUtil { + private final Parser parser; + private final HtmlRenderer renderer; + public String markdown(String markdown) { - Parser parser = Parser.builder().build(); Node document = parser.parse(markdown); - HtmlRenderer renderer = HtmlRenderer.builder().build(); - return renderer.render(document); + String html = renderer.render(document); + + System.out.println("Converted HTML: " + html); + + // Sanitize HTML + PolicyFactory policy = new HtmlPolicyBuilder() + .allowElements("h1", "h2", "h3", "p", "b", "i", "em", "strong", "img", "a", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td", "del", "blockquote", "code", "pre", "input", "hr") + .allowUrlProtocols("https", "http") + .allowAttributes("href", "target").onElements("a") + .allowAttributes("src", "alt").onElements("img") + .allowAttributes("type", "checked", "disabled").onElements("input") + .allowAttributes("border", "cellspacing", "cellpadding").onElements("table") + .requireRelNofollowOnLinks() + .toFactory(); + + String safeHtml = policy.sanitize(html); + System.out.println("After sanitize: " + safeHtml); + return safeHtml; // Return the sanitized HTML } } \ No newline at end of file diff --git a/src/main/resources/static/markdown.css b/src/main/resources/static/markdown.css new file mode 100644 index 0000000..c9f8ac7 --- /dev/null +++ b/src/main/resources/static/markdown.css @@ -0,0 +1,36 @@ +table { + border-collapse: collapse; /* 테두리 */> +} + +thead { + background-color: #91c0fd; + font-weight: bold; + color: white; + padding: 12px; + text-align: center; +} + +th, td { + padding: 12px; + border: 3px solid #91c0fd; +} + /* 인용 */ +blockquote { + /* 왼쪽 경계선 */ + border-left: 4px solid #cccccc; + /* 들여 쓰기와 여백 */ + padding-left: 20px; + margin-left: 0; + /* 글꼴 속성 (기울어짐, 색상 등) */ + font-style: italic; + color: #666666; +} + +a { + text-decoration: none; + color: inherit; +} + +img { + max-width: 100%; +} diff --git a/src/main/resources/templates/comment/answer_comment.html b/src/main/resources/templates/comment/answer_comment.html index 4d38ed1..da339c4 100644 --- a/src/main/resources/templates/comment/answer_comment.html +++ b/src/main/resources/templates/comment/answer_comment.html @@ -27,24 +27,24 @@
0
-
    - -
+ + +
    @@ -633,19 +632,6 @@ } }); } - - function Answer_CommentForm__submit(form) { - // username 이(가) 올바른지 체크 - - form.commentContents.value = form.commentContents.value.trim(); // 입력란의 입력값에 있을지 모르는 좌우공백제거 - - if (form.commentContents.value.length === 0) { - alert('내용을 입력해주세요'); - form.commentContents.focus(); - return; - } - form.submit(); // 폼 발송 - } \ No newline at end of file diff --git a/src/main/resources/templates/comment/question_comment.html b/src/main/resources/templates/comment/question_comment.html index 69ec585..ade37b8 100644 --- a/src/main/resources/templates/comment/question_comment.html +++ b/src/main/resources/templates/comment/question_comment.html @@ -27,8 +27,7 @@
    0
    -
      - -
    +
+ + +
    @@ -622,19 +621,6 @@ }); } - - function CommentForm__submit(form) { - // username 이(가) 올바른지 체크 - - form.commentContents.value = form.commentContents.value.trim(); // 입력란의 입력값에 있을지 모르는 좌우공백제거 - - if (form.commentContents.value.length === 0) { - alert('내용을 입력해주세요'); - form.commentContents.focus(); - return; - } - form.submit(); // 폼 발송 - } \ No newline at end of file diff --git a/src/main/resources/templates/common/layout.html b/src/main/resources/templates/common/layout.html index f7f85f8..d874f0c 100644 --- a/src/main/resources/templates/common/layout.html +++ b/src/main/resources/templates/common/layout.html @@ -25,16 +25,6 @@ color:inherit; text-decoration:inherit; } - - ul, li { - /* 앞에 점 없애기 */ - list-style:none; - /* 안쪽 여백 제거 */ - padding:0; - /* 바깥쪽 여백 제거 */ - margin:0; - } - diff --git a/src/main/resources/templates/question/question_detail.html b/src/main/resources/templates/question/question_detail.html index 40ddb98..dbc21d9 100644 --- a/src/main/resources/templates/question/question_detail.html +++ b/src/main/resources/templates/question/question_detail.html @@ -1,10 +1,20 @@ + + + + + + + +

    -
    +
    modified at
    @@ -72,7 +82,7 @@
    -
    +
    modified at
    @@ -168,8 +178,30 @@