Refactor Summernote initialization for consistency and improved readability

This commit is contained in:
Rinjae
2025-12-30 21:37:37 +09:00
parent 9a1e73ec4f
commit a7fe811fb9
9 changed files with 320 additions and 144 deletions
+13 -32
View File
@@ -9,16 +9,7 @@
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/applicationContext.xml</param-value>
</context-param>
<!-- XSS Filter -->
<filter>
<filter-name>CrossScriptingFilter</filter-name>
<filter-class>com.eactive.eai.rms.common.filter.CrossScriptingFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>CrossScriptingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- Encoding Filter (반드시 다른 필터보다 먼저 적용되어야 함) -->
<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
@@ -31,30 +22,20 @@
<param-value>true</param-value>
</init-param>
</filter>
<!-- encodingFilter를 모든 요청에 먼저 적용 -->
<filter-mapping>
<filter-name>encodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- XSS Filter (encodingFilter 다음에 적용) -->
<filter>
<filter-name>encodingFilterEUCKR</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>utf-8</param-value>
</init-param>
<init-param>
<param-name>forceEncoding</param-name>
<param-value>true</param-value>
</init-param>
<filter-name>CrossScriptingFilter</filter-name>
<filter-class>com.eactive.eai.rms.common.filter.CrossScriptingFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>encodingFilterEUCKR</filter-name>
<url-pattern>*.excel</url-pattern>
</filter-mapping>
<filter-mapping>
<filter-name>encodingFilter</filter-name>
<url-pattern>*.file</url-pattern>
</filter-mapping>
<!-- Tomcat 에서는 필요. Weblogic 에서는 불필요함 -->
<filter-mapping>
<filter-name>encodingFilter</filter-name>
<url-pattern>*.json</url-pattern>
<filter-name>CrossScriptingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!--
@@ -168,6 +149,6 @@
</jsp-config>
<session-config>
<session-timeout>30</session-timeout>
<session-timeout>30</session-timeout>
</session-config>
</web-app>
+239
View File
@@ -60,8 +60,247 @@
<script src="<c:url value="/addon/summernote/summernote-cleaner.js"/>"></script>
<script src="<c:url value="/addon/summernote/lang/summernote-ko-KR.js"/>"></script>
<!-- Summernote 스타일 -->
<style>
.note-editable img {
max-width: 100%;
cursor: pointer;
}
.note-editable img.note-image-resizing {
outline: 2px solid #007bff;
}
/* 이미지 리사이즈 핸들 스타일 */
.note-control-sizing {
display: block !important;
}
.note-control-holder {
position: relative;
}
.note-control-holder .note-control-sizing {
position: absolute;
width: 10px;
height: 10px;
border: 1px solid #333;
background-color: #fff;
}
/* 코드뷰 모드 가로 사이즈 고정 */
.note-editor .note-codable {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
resize: vertical !important;
white-space: pre-wrap !important;
word-wrap: break-word !important;
overflow-x: auto !important;
}
.note-editor.note-frame {
overflow: hidden;
}
</style>
<script language="javascript">
/**
* Summernote 에디터 공통 초기화 함수
* - 이미지 붙여넣기 시 data-uri(Base64) 방식으로 처리
* - 이미지 리사이징 지원
* @param {string} selector - 에디터 selector (예: '#contents')
* @param {object} customOptions - 추가 옵션 (선택사항)
*/
function initSummernote(selector, customOptions) {
customOptions = customOptions || {};
// 기본 설정
var defaultOptions = {
placeholder: customOptions.placeholder || '여기에 내용을 입력하세요.',
height: customOptions.height || 300,
lang: 'ko-KR',
toolbar: customOptions.toolbar || [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link', 'picture']],
['view', ['fullscreen', 'codeview', 'help']]
],
// 이미지 팝오버 설정 - 리사이징 지원
popover: {
image: [
['resize', ['resizeFull', 'resizeHalf', 'resizeQuarter', 'resizeNone']],
['float', ['floatLeft', 'floatRight', 'floatNone']],
['remove', ['removeMedia']]
]
},
callbacks: {
onInit: function() {
var $editable = $(selector).next('.note-editor').find('.note-editable');
$editable.addClass('editor-content');
$editable.on('keydown', function(e) {
if (e.keyCode === 8) {
e.stopPropagation();
}
});
// 커스텀 onInit 콜백 호출
if (customOptions.callbacks && customOptions.callbacks.onInit) {
customOptions.callbacks.onInit.call(this);
}
},
// 이미지 업로드 처리 - data-uri(Base64) 방식
onImageUpload: function(files) {
for (var i = 0; i < files.length; i++) {
insertImageAsDataUri(files[i], $(selector));
}
},
// 붙여넣기 시 이미지 처리 (MS Office 등에서 복사한 이미지 포함)
onPaste: function(e) {
var clipboardData = e.originalEvent.clipboardData;
if (!clipboardData || !clipboardData.items) return;
var items = clipboardData.items;
var imageFile = null;
// 1. 먼저 순수 이미지 파일이 있는지 확인
for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
imageFile = items[i].getAsFile();
break;
}
}
// 2. 이미지 파일이 있으면 data-uri로 변환하여 삽입
if (imageFile) {
e.preventDefault();
e.stopPropagation();
insertImageAsDataUri(imageFile, $(selector));
return;
}
// 3. HTML 붙여넣기인 경우 (MS Office 등) - 이미지 태그 정리
var htmlData = clipboardData.getData('text/html');
if (htmlData && htmlData.indexOf('<img') !== -1) {
e.preventDefault();
e.stopPropagation();
// HTML에서 이미지 src 추출 및 정리
var cleanedHtml = cleanPastedHtml(htmlData);
// 에디터에 포커스 후 HTML 삽입
$(selector).summernote('focus');
setTimeout(function() {
$(selector).summernote('pasteHTML', cleanedHtml);
}, 10);
return;
}
}
}
};
// 커스텀 옵션 병합 (callbacks는 위에서 별도 처리)
var mergedOptions = $.extend(true, {}, defaultOptions, customOptions);
// callbacks는 기본 콜백을 유지하면서 커스텀 콜백 추가
mergedOptions.callbacks = defaultOptions.callbacks;
if (customOptions.callbacks) {
// onInit은 위에서 이미 처리됨
// 다른 커스텀 콜백 추가
for (var key in customOptions.callbacks) {
if (key !== 'onInit' && key !== 'onImageUpload' && key !== 'onPaste') {
mergedOptions.callbacks[key] = customOptions.callbacks[key];
}
}
}
$(selector).summernote(mergedOptions);
}
/**
* 이미지를 Base64 data-uri로 변환하여 에디터에 삽입
* @param {File} file - 이미지 파일
* @param {jQuery} $editor - summernote 에디터 jQuery 객체
*/
function insertImageAsDataUri(file, $editor) {
// 파일 크기 제한 (5MB)
var maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
alert('이미지 크기는 5MB를 초과할 수 없습니다.');
return;
}
// 이미지 타입 확인
if (!file.type.match(/image\/(jpeg|jpg|png|gif|bmp|webp)/i)) {
alert('지원하지 않는 이미지 형식입니다. (jpeg, png, gif, bmp, webp만 지원)');
return;
}
var reader = new FileReader();
reader.onload = function(e) {
var dataUri = e.target.result;
$editor.summernote('insertImage', dataUri, function($image) {
// 이미지 스타일 설정
$image.css({
'max-width': '100%',
'height': 'auto'
});
$image.attr('data-filename', file.name);
});
};
reader.onerror = function() {
alert('이미지 읽기에 실패했습니다.');
};
reader.readAsDataURL(file);
}
/**
* 붙여넣기된 HTML 정리 (MS Office 등에서 복사한 내용)
* - 불필요한 Office 속성 제거
* - 이미지 태그 정리 (alt, v:shapes 등 제거)
* @param {string} html - 원본 HTML
* @returns {string} 정리된 HTML
*/
function cleanPastedHtml(html) {
// 임시 div에서 HTML 파싱
var $temp = $('<div>').html(html);
// 이미지 태그 정리
$temp.find('img').each(function() {
var $img = $(this);
var src = $img.attr('src');
// src가 없거나 file:// 프로토콜이면 제거
if (!src || src.indexOf('file://') === 0) {
$img.remove();
return;
}
// 불필요한 속성 제거 (Office 관련)
var attributesToRemove = ['alt', 'v:shapes', 'o:title', 'style', 'class', 'id', 'name'];
attributesToRemove.forEach(function(attr) {
$img.removeAttr(attr);
});
// 깨끗한 스타일만 적용
$img.css({
'max-width': '100%',
'height': 'auto'
});
});
// VML 태그 제거 (v:, o:, w: 등)
var cleanedHtml = $temp.html();
cleanedHtml = cleanedHtml.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
cleanedHtml = cleanedHtml.replace(/<o:[^>]*>[\s\S]*?<\/o:[^>]*>/gi, '');
cleanedHtml = cleanedHtml.replace(/<w:[^>]*>[\s\S]*?<\/w:[^>]*>/gi, '');
cleanedHtml = cleanedHtml.replace(/<!\[if[^>]*>[\s\S]*?<!\[endif\]>/gi, '');
// Office 네임스페이스 속성 제거
cleanedHtml = cleanedHtml.replace(/\s*v:[a-z]+="[^"]*"/gi, '');
cleanedHtml = cleanedHtml.replace(/\s*o:[a-z]+="[^"]*"/gi, '');
return cleanedHtml;
}
function buttonControl() {
if (arguments.length == 0) {
$("img[level], button[level]").hide();
@@ -67,28 +67,10 @@
buttonControl(false);
}
$('#faqAnswer').summernote({
// Summernote 에디터 초기화 (이미지 붙여넣기 data-uri 방식, 리사이징 지원)
initSummernote('#faqAnswer', {
placeholder: '여기에 답변을 입력하세요.',
height: 300,
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link', 'picture']],
['view', ['fullscreen', 'codeview', 'help']]
],
callbacks: {
onInit: function() {
$('.note-editable').addClass('editor-content');
$('.note-editable').on('keydown', function(e) {
if (e.keyCode === 8) {
e.stopPropagation();
}
});
}
}
height: 300
});
if (key) {
@@ -109,26 +109,10 @@
var returnUrl = getReturnUrlForReturn();
var key = "${param.id}";
$('#responseDetail').summernote({
height: 200,
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link']],
['view', ['codeview', 'help']]
],
callbacks: {
onInit: function () {
$('.note-editable').on('keydown', function (e) {
if (e.keyCode === 8) {
e.stopPropagation();
}
});
}
}
// Summernote 에디터 초기화 (이미지 붙여넣기 data-uri 방식, 리사이징 지원)
initSummernote('#responseDetail', {
placeholder: '여기에 답변을 입력하세요.',
height: 200
});
if (key) {
@@ -137,28 +137,10 @@
init();
$('#contents').summernote({
// Summernote 에디터 초기화 (이미지 붙여넣기 data-uri 방식, 리사이징 지원)
initSummernote('#contents', {
placeholder: '여기에 내용을 입력하세요.',
height: 300,
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link']],
['view', ['codeview', 'help']]
],
callbacks: {
onInit: function() {
$('.note-editable').addClass('editor-content');
$('.note-editable').on('keydown', function(e) {
if (e.keyCode === 8) {
e.stopPropagation();
}
});
}
}
height: 300
});
if (key) {
@@ -127,28 +127,10 @@
}
});
$('#contents').summernote({
placeholder: '여기에 답변을 입력하세요.',
height: 300,
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link']],
['view', ['fullscreen', 'codeview', 'help']]
],
callbacks: {
onInit: function() {
$('.note-editable').addClass('editor-content');
$('.note-editable').on('keydown', function(e) {
if (e.keyCode === 8) {
e.stopPropagation();
}
});
}
}
// Summernote 에디터 초기화 (이미지 붙여넣기 data-uri 방식, 리사이징 지원)
initSummernote('#contents', {
placeholder: '여기에 약관 내용을 입력하세요.',
height: 300
});
if (agreementsType && revision) {
@@ -54,7 +54,8 @@ function detail(key){
}
$(document).ready(function() {
$('#emailTemplate').summernote({
// Summernote 에디터 초기화 (이미지 붙여넣기 data-uri 방식, 리사이징 지원)
initSummernote('#emailTemplate', {
placeholder: '여기에 내용을 입력하세요.',
height: 300,
toolbar: [
@@ -65,16 +66,7 @@ $(document).ready(function() {
['table', ['table']],
['insert', ['link', 'picture', 'video']],
['view', ['codeview', 'help']]
],
callbacks: {
onInit: function() {
$('.note-editable').on('keydown', function(e) {
if (e.keyCode === 8) {
e.stopPropagation();
}
});
}
}
]
});
var key ="${param.id}";
@@ -69,6 +69,7 @@
}
function initializeSummernote() {
// Summernote 에디터 초기화 (이미지 붙여넣기 data-uri 방식, 리사이징 지원)
var commonOptions = {
height: 300,
toolbar: [
@@ -79,21 +80,12 @@
['table', ['table']],
['insert', ['link', 'picture', 'video']],
['view', ['codeview', 'help']]
],
callbacks: {
onInit: function () {
$('.note-editable').on('keydown', function (e) {
if (e.keyCode === 8) {
e.stopPropagation();
}
});
}
}
]
};
$('#description').summernote(commonOptions);
$('#apiRequestSpec').summernote(commonOptions);
$('#apiResponseSpec').summernote(commonOptions);
initSummernote('#description', commonOptions);
initSummernote('#apiRequestSpec', commonOptions);
initSummernote('#apiResponseSpec', commonOptions);
}
function detail(key) {
@@ -15,12 +15,9 @@ class RequestWrapper extends HttpServletRequestWrapper {
// GS인증 크로스사이트스크립트 보안성 문제로 XSS 공격을 방지하기 위한 악성 스크립트 패턴 정의.
// 2025.05.27 패턴 추가
// 2025.12.30 src 패턴 수정 - 화이트리스트 방식 (http, https, data:image/ 만 허용)
private static final Pattern[] PATTERNS = new Pattern[] {
Pattern.compile("<script>(.*?)</script>", Pattern.CASE_INSENSITIVE),
Pattern.compile("src[\r\n]*=[\r\n]*\\\'(.*?)\\\'",
Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
Pattern.compile("src[\r\n]*=[\r\n]*\\\"(.*?)\\\"",
Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
Pattern.compile("</script>", Pattern.CASE_INSENSITIVE),
Pattern.compile("<script(.*?)>",
Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
@@ -46,6 +43,16 @@ class RequestWrapper extends HttpServletRequestWrapper {
Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL)
};
// src 속성 화이트리스트 패턴 - data:image/만 허용
private static final Pattern SRC_ATTR_PATTERN = Pattern.compile(
"(src\\s*=\\s*)([\"'])([^\"']*?)\\2",
Pattern.CASE_INSENSITIVE);
// 허용되는 src 값 패턴 (화이트리스트) - data:image/만 허용
private static final Pattern ALLOWED_SRC_PATTERN = Pattern.compile(
"^data:image/.*",
Pattern.CASE_INSENSITIVE);
public RequestWrapper(HttpServletRequest servletRequest) {
super(servletRequest);
@@ -123,6 +130,10 @@ class RequestWrapper extends HttpServletRequestWrapper {
// script 문자열을 완전히 제거
value = value.replaceAll("(?i)\\bscript\\b", "");
// src 속성 화이트리스트 필터링 - 허용된 프로토콜만 통과
value = filterSrcAttribute(value);
StringBuilder sb = new StringBuilder();
value = convertChars(value, sb);
@@ -133,6 +144,37 @@ class RequestWrapper extends HttpServletRequestWrapper {
return value;
}
/**
* src 속성을 화이트리스트 방식으로 필터링
* 허용: data:image/ (Base64 이미지)
* 비허용: 그 외 모든 프로토콜
*/
private String filterSrcAttribute(String value) {
if (value == null || !value.toLowerCase().contains("src")) {
return value;
}
Matcher matcher = SRC_ATTR_PATTERN.matcher(value);
StringBuffer result = new StringBuffer();
while (matcher.find()) {
String srcPrefix = matcher.group(1); // src=
String quote = matcher.group(2); // " 또는 '
String srcValue = matcher.group(3); // src 값
// 빈 값이거나 화이트리스트에 매칭되면 유지
if (srcValue.isEmpty() || ALLOWED_SRC_PATTERN.matcher(srcValue).matches()) {
matcher.appendReplacement(result, Matcher.quoteReplacement(srcPrefix + quote + srcValue + quote));
} else {
// 허용되지 않는 프로토콜은 src="" 로 대체
matcher.appendReplacement(result, Matcher.quoteReplacement(srcPrefix + quote + quote));
}
}
matcher.appendTail(result);
return result.toString();
}
// '(', ')', '"' 문자는 getMethod()가 "get"일 때만 변환됨.
// 이는 gridData에서 사용되는 큰따옴표(")와 변환 함수에서 사용하는 괄호('(', ')')의 처리를 위함.
private String convertChars(String value, StringBuilder sb) {