diff --git a/build.gradle b/build.gradle index 59457ed..3f9d872 100644 --- a/build.gradle +++ b/build.gradle @@ -83,7 +83,10 @@ dependencies { implementation group: 'com.fasterxml.woodstox', name: 'woodstox-core', version: '6.5.1' - implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + // Thymeleaf 3.1 코어가 #temporals 를 내장 제공 → java8time extras 를 함께 두면 표현식 객체가 중복 등록된다. + implementation('org.springframework.boot:spring-boot-starter-thymeleaf') { + exclude group: 'org.thymeleaf.extras', module: 'thymeleaf-extras-java8time' + } implementation 'org.springframework.boot:spring-boot-starter-security' implementation('org.springframework.boot:spring-boot-starter-cache') implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -91,33 +94,40 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-jdbc' developmentOnly 'org.springframework.boot:spring-boot-devtools' - implementation 'org.springframework:spring-expression:5.3.30' + // spring-expression 개별 pin 제거: 아래 ext 의 spring-framework.version 이 전 모듈을 일괄 관리한다. + // 개별 pin 이 남아 있으면 다른 spring-* 모듈보다 낮은 버전으로 고정되어 버전이 어긋난다. implementation group: 'xalan', name: 'xalan', version: '2.7.3' implementation 'org.hibernate:hibernate-envers:5.6.15.Final' - implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.0.0' + implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.4.0' // 3.0.0 은 thymeleaf 3.0 전용 implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5' - implementation 'com.github.ua-parser:uap-java:1.5.3' + // uap-java 제거: 소스 전체에 ua_parser 참조 0건이고, 1.5.3 은 snakeyaml 2.x 에서 삭제된 + // SafeConstructor() no-arg 를 호출해 NoSuchMethodError 를 낸다. 되살릴 경우 1.6.1 이상. implementation 'org.apache.httpcomponents:httpclient:4.5.14' implementation 'com.navercorp.lucy:lucy-xss-servlet:2.0.1' implementation 'javax.servlet:javax.servlet-api:4.0.0' implementation 'org.jasypt:jasypt:1.9.3' implementation 'xerces:xercesImpl:2.12.2' - implementation 'org.apache.commons:commons-lang3:3.12.0' + // Uncontrolled recursion in ClassUtils.getClass(...) on very long inputs. 3.18.0+ 에서 수정. + implementation 'org.apache.commons:commons-lang3:3.20.0' implementation 'org.apache.commons:commons-collections4:4.4' implementation 'commons-net:commons-net:3.9.0' - implementation('commons-beanutils:commons-beanutils:1.9.4') { + // CVE-2025-48734 (PropertyUtilsBean 이 enum 의 declaredClass 프로퍼티 노출 → ClassLoader 접근/RCE). + // 1.11.0 부터 SuppressPropertiesBeanIntrospector 가 기본 활성이라 declaredClass 접근이 차단된다. + // 이 앱의 호출부(PasswordMatchValidator / PasswordRuleValidator / AuthNumberValidator)는 + // 어노테이션에 박힌 고정 프로퍼티명만 넘기므로 외부 입력 경로는 없지만 버전은 올려 둔다. + // 1.11.0 = Java 8 바이트코드(major 52), PropertyUtils.getProperty/getNestedProperty API 동일. + implementation('commons-beanutils:commons-beanutils:1.11.0') { // exclude group: 'commons-collections', module: 'commons-collections' } implementation 'org.mapstruct:mapstruct:1.5.5.Final' - // WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. JDK8 호환. - implementation 'com.fasterxml.jackson.core:jackson-core:2.18.6' - implementation 'com.fasterxml.jackson.core:jackson-annotations:2.18.6' - implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.6' + // jackson 개별 pin 제거: 아래 ext 의 jackson-bom.version 이 전 모듈을 일괄 관리한다. + // 개별 pin 은 BOM 보다 우선하므로 남겨 두면 BOM 만 올렸을 때 core/annotations/databind 가 + // 옛 버전에 고정돼 버전이 어긋난다(실제로 그런 상태였다). implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3' @@ -127,7 +137,10 @@ dependencies { implementation 'net.bytebuddy:byte-buddy:1.14.5' // Commons FileUpload (WAS 독립적인 multipart 처리) - implementation 'commons-fileupload:commons-fileupload:1.5' + // CVE-2025-48976 (멀티파트 파트 헤더 크기 제한 부재 → DoS). 1.6.0 에서 partHeaderSizeMax 도입. + // 주의: 1.6 부터 파트 헤더 총량 기본 상한이 10240 → 512 바이트로 줄었다(DEFAULT_PART_HEADER_SIZE_MAX). + // 한글 파일명은 UTF-8 로 3바이트/자라 Content-Disposition 이 길어질 수 있어 실측으로 여유를 확인했다. + implementation 'commons-fileupload:commons-fileupload:1.6.0' implementation 'commons-io:commons-io:2.15.1' @@ -145,6 +158,49 @@ ext { springMavenArtifactVersion = '5.3.30' encoding = 'UTF-8' profile = 'local' + + // 내장 Tomcat 버전 상향 (Spring Boot 2.7.18 기본값 9.0.83 → 9.0.120). + // Boot BOM 의 tomcat.version 프로퍼티를 덮어써서 tomcat-embed-core/-el/-websocket 이 함께 올라간다. + // 9.0.x 계열 유지 = Servlet 4.0 / javax.* 네임스페이스 그대로, JDK8 호환. + set('tomcat.version', '9.0.120') + + // CVE-2022-1471 (snakeyaml Constructor 임의 타입 역직렬화 → RCE). Boot 2.7.18 BOM 기본값 1.30 → 2.6. + // 앱/프레임워크 실경로는 이미 SafeConstructor 계열(OriginTrackedYamlLoader, spring-beans + // FilteringConstructor, swagger-parser DeserializationUtils)이라 익스플로잇 경로는 없었으나 + // SCA 는 버전으로 판정하므로 2.x 로 올린다. snakeyaml 2.6 = Java 8 바이트코드(major 52). + // 2.6 인 이유: swagger-core 2.2.52 / swagger-parser-v3 2.1.45 가 요구하는 버전이라 강등이 없다. + // 부수 효과: swagger-parser 가 호출하는 LoaderOptions.setCodePointLimit(1.32+ API) 도 해소. + set('snakeyaml.version', '2.6') + + // jackson 전 모듈 버전 통일(Boot 2.7.18 BOM 기본 2.13.5). 2.18.x 는 JDK8 호환 라인이다. + // 이유 3가지 + // 1) snakeyaml 2.x 는 ParserImpl(StreamReader) 를 제거했고 jackson-dataformat-yaml 은 + // 2.15+ 부터 ParserImpl(StreamReader, LoaderOptions) 를 쓴다 — 위 snakeyaml 상향의 전제. + // 2) WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. + // 3) jackson-databind PolymorphicTypeValidator 우회(제네릭 타입 인자 미검증) — 2.18.8 에서 수정. + // 이 앱은 다형성 역직렬화(activateDefaultTyping/@JsonTypeInfo)를 쓰지 않아 노출 경로는 없다. + // 2.18.x 마지막 패치를 쓴다. + set('jackson-bom.version', '2.18.10') + + // Thymeleaf SSTI (≤3.1.3.RELEASE: 표현식 접근 객체 제한 우회 → 템플릿 인젝션). 3.0.x 는 EOL 이라 + // 백포트가 없어 3.1.4 로 올린다. JDK8/Spring5 유지: thymeleaf 3.1.4 / thymeleaf-spring5 3.1.4 / + // extras-springsecurity5 3.1.5 / layout-dialect 3.4.0 모두 Java8 바이트코드(major 52), 패키지도 + // org.thymeleaf.spring5 + javax.servlet 그대로다. + // Boot 2.7 ThymeleafAutoConfiguration 이 호출하는 setter 는 3.1.4 에 전부 존재함(확인함). + // 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException). + set('thymeleaf.version', '3.1.4.RELEASE') + set('thymeleaf-extras-springsecurity.version', '3.1.5.RELEASE') + + // Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음). + // 5.3.32~5.3.39 구간 CVE 정리용. 5.3.x 는 OSS EOL 이라 이 위로는 상용(Enterprise) 빌드뿐이다. + // 남는 5.3.x 지적(CVE-2026-41855 JMS Jackson 역직렬화, CVE-2016-1000027 HttpInvoker)은 + // 5.3 계열에 수정본이 없고 앱이 JMS/HttpInvoker 를 쓰지 않으므로 억제 항목으로 따로 관리한다. + set('spring-framework.version', '5.3.39') + + // Spring Security 5.7.x OSS 마지막 릴리스(Boot 2.7.18 BOM 기본 5.7.11). + // CVE-2026-22732(응답 커밋 후 보안 헤더 미기록)는 수정본이 5.7.22/5.8.24 = Enterprise 전용이라 + // OSS 로는 올릴 수 없다. 우회책(HeaderWriterFilter.shouldWriteHeadersEagerly=true)은 동작 변경이라 분리 검토. + set('spring-security.version', '5.7.14') } diff --git a/ci/dependency-check-suppressions.xml b/ci/dependency-check-suppressions.xml index e70259b..f4e941e 100644 --- a/ci/dependency-check-suppressions.xml +++ b/ci/dependency-check-suppressions.xml @@ -28,4 +28,94 @@ --> + + + + + + ^pkg:maven/org\.springframework\.boot/.*@2\.7\..*$ + CVE-2026-40974 + + + + + + ^pkg:maven/org\.springframework\.boot/spring-boot-starter-thymeleaf@.*$ + CVE-2026-40477 + CVE-2026-40478 + + + + + + ^pkg:maven/org\.springframework/spring-.*@5\.3\..*$ + CVE-2026-41855 + + + + + + ^pkg:maven/org\.springframework/spring-web@5\.3\..*$ + CVE-2016-1000027 + + + + + + ^pkg:maven/org\.springframework\.security/spring-security-.*@5\.7\..*$ + CVE-2026-22732 + + diff --git a/src/main/java/com/eactive/apim/portal/apps/apiservice/service/ApiSpecService.java b/src/main/java/com/eactive/apim/portal/apps/apiservice/service/ApiSpecService.java index 3536942..f96db0a 100644 --- a/src/main/java/com/eactive/apim/portal/apps/apiservice/service/ApiSpecService.java +++ b/src/main/java/com/eactive/apim/portal/apps/apiservice/service/ApiSpecService.java @@ -36,6 +36,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; import org.springframework.stereotype.Service; @Service @@ -155,10 +156,10 @@ public class ApiSpecService { updateReferences(pathItem, apiId); String tmpFullPath = StringUtils.isNotEmpty(basePath) - ? StringUtils.join(basePath, "/", StringUtils.removeStart(path, "/")) + ? StringUtils.join(basePath, "/", Strings.CS.removeStart(path, "/")) : path; - final String fullPath = StringUtils.replacePattern(tmpFullPath, "//+", "/"); + final String fullPath = tmpFullPath.replaceAll("//+", "/"); // HTTP 메소드별로 중복 체크 pathItem.readOperationsMap().forEach((httpMethod, operation) -> { @@ -294,7 +295,7 @@ public class ApiSpecService { return StringUtils.EMPTY; } Server server = api.getServers().get(0); - return StringUtils.removeEnd(server.getUrl(), "/"); + return Strings.CS.removeEnd(server.getUrl(), "/"); } private void mergeComponents(OpenAPI currentAPI, String apiId, String apiName, Map mergedComponents, ObjectMapper objectMapper) { diff --git a/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java b/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java index 8afd94e..d06550b 100644 --- a/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java +++ b/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java @@ -2,6 +2,7 @@ package com.eactive.apim.portal.common.exception; import java.util.Arrays; +import java.util.regex.Pattern; import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; @@ -36,23 +37,27 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes; @RequiredArgsConstructor public class PortalGlobalExceptionHandler { + private static final String REDIRECT_PREFIX = "redirect:"; + private static final String REDIRECT_HOME = REDIRECT_PREFIX + "/"; + private static final String REDIRECT_LOGIN_AUTH = REDIRECT_PREFIX + "/login?reason=auth"; + private final Logger log = LoggerFactory.getLogger(getClass()); private final PortalProperties portalProperties; private final Environment environment; @ExceptionHandler(value = NotFoundException.class) public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) { - return new ModelAndView("redirect:/"); + return new ModelAndView(REDIRECT_HOME); } @ExceptionHandler(value = MethodArgumentTypeMismatchException.class) public ModelAndView handleMethodArgumentTypeMismatchException(HttpServletRequest request, MethodArgumentTypeMismatchException ex) { - return new ModelAndView("redirect:/"); + return new ModelAndView(REDIRECT_HOME); } @ExceptionHandler(value = UserNotLoginException.class) public ModelAndView handleUserNotLoginException(HttpServletRequest request, UserNotLoginException ex) { - return new ModelAndView("redirect:/login?reason=auth"); + return new ModelAndView(REDIRECT_LOGIN_AUTH); } @ExceptionHandler(value = AccessDeniedException.class) @@ -61,7 +66,7 @@ public class PortalGlobalExceptionHandler { if (!SecurityUtil.isAuthenticated()) { // 원래 요청 페이지를 세션에 저장 → 로그인+2FA 완료 후 LoginFinalizer 가 복귀시킨다. savePostLoginRedirect(request); - return new ModelAndView("redirect:/login?reason=auth"); + return new ModelAndView(REDIRECT_LOGIN_AUTH); } log.warn("접근 권한 없음: loginId={}, uri={}", StringMaskingUtil.maskLoginId(SecurityUtil.getCurrentLoginId()), request.getRequestURI()); ModelAndView modelAndView = new ModelAndView("error"); @@ -122,9 +127,40 @@ public class PortalGlobalExceptionHandler { || path.startsWith("/favicon")); } + /** + * 리다이렉트/뷰 이름으로 허용할 문자. 스킴 구분자(:)·중괄호·달러를 막아 + * 외부 URL 이나 Thymeleaf 표현식이 뷰 이름으로 흘러드는 것을 차단한다. + */ + private static final Pattern SAFE_REDIRECT_TARGET = Pattern.compile("[A-Za-z0-9._/?=&%-]+"); + @ExceptionHandler(value = PortalRedirectException.class) public ModelAndView handlePortalRedirectException(HttpServletRequest request, PortalRedirectException ex) { - return new ModelAndView(ex.getMessage()); + // PortalRedirectException 은 super(message) 를 호출하지 않으므로 getMessage() 는 항상 null 이다. + // 이전 구현은 그 null 을 뷰 이름으로 넘겨(= 뷰 미지정) 요청 URL 기준으로 뷰가 추론되게 만들었다. + // 실제 대상은 redirectPage 필드다. + String target = ex.getRedirectPage(); + if (!isSafeRedirectTarget(target)) { + log.warn("허용되지 않은 리다이렉트 대상 - uri={}, target={}", request.getRequestURI(), target); + return new ModelAndView(REDIRECT_HOME); + } + return new ModelAndView(target); + } + + /** 내부 경로/뷰 이름만 허용(외부 URL·프로토콜 상대 URL·표현식 문자 차단) */ + private boolean isSafeRedirectTarget(String target) { + if (target == null) { + return false; + } + String path = target; + if (path.startsWith(REDIRECT_PREFIX)) { + path = path.substring(REDIRECT_PREFIX.length()); + } else if (path.startsWith("forward:")) { + path = path.substring("forward:".length()); + } + if (path.isEmpty() || path.startsWith("//")) { // //evil.example 형태의 외부 리다이렉트 차단 + return false; + } + return SAFE_REDIRECT_TARGET.matcher(path).matches(); } @ExceptionHandler(value = {IllegalArgumentException.class}) @@ -140,7 +176,7 @@ public class PortalGlobalExceptionHandler { public ModelAndView handleHttpRequestMethodNotSupportedException(HttpServletRequest request, HttpRequestMethodNotSupportedException ex) { log.error(ex.getMessage()); ModelAndView modelAndView = new ModelAndView(); - modelAndView.setViewName("redirect:/"); + modelAndView.setViewName(REDIRECT_HOME); return modelAndView; } @@ -184,7 +220,7 @@ public class PortalGlobalExceptionHandler { public ModelAndView handleInvalidFileException(HttpServletRequest request, RedirectAttributes redirectAttributes, InvalidFileException ex) { ModelAndView modelAndView = new ModelAndView(); redirectAttributes.addFlashAttribute("error", ex.getMessage()); - modelAndView.setViewName("redirect:" + request.getRequestURI()); + modelAndView.setViewName(REDIRECT_PREFIX + request.getRequestURI()); return modelAndView; } @@ -195,7 +231,7 @@ public class PortalGlobalExceptionHandler { String errorMessage = "파일 크기가 허용된 최대 용량(" + maxSize + ")을 초과했습니다."; redirectAttributes.addFlashAttribute("error", errorMessage); ModelAndView modelAndView = new ModelAndView(); - modelAndView.setViewName("redirect:" + request.getRequestURI()); + modelAndView.setViewName(REDIRECT_PREFIX + request.getRequestURI()); return modelAndView; } } diff --git a/src/main/java/com/eactive/apim/portal/common/validator/CellPhoneValidator.java b/src/main/java/com/eactive/apim/portal/common/validator/CellPhoneValidator.java index 86cc89c..3cbc272 100644 --- a/src/main/java/com/eactive/apim/portal/common/validator/CellPhoneValidator.java +++ b/src/main/java/com/eactive/apim/portal/common/validator/CellPhoneValidator.java @@ -1,6 +1,7 @@ package com.eactive.apim.portal.common.validator; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; import org.springframework.stereotype.Component; import javax.validation.ConstraintValidator; @@ -39,14 +40,8 @@ public class CellPhoneValidator implements ConstraintValidator + * commons-fileupload 1.6.0(CVE-2025-48976 수정)부터 이 상한이 생겼고 기본값이 512 바이트다. + * 한글 파일명은 UTF-8 로 3바이트/자라 Content-Disposition 이 금방 커진다 — 실측상 기본값 512 로는 + * 한글 약 137자 이상 파일명에서 업로드가 실패하고, 예외도 "Maximum upload size exceeded" 로 감싸져 + * 원인 파악이 어렵다. OS 파일명 상한(255자)이 전부 한글이어도 통과하도록 2048 로 둔다. + * (1.5 이전에는 사실상 10240 이었으므로 이 값도 그보다 5배 엄격하다.) + *

+ */ + private static final int PART_HEADER_SIZE_MAX = 2048; + private final PortalProperties portalProperties; /** @@ -31,7 +45,14 @@ public class MultipartConfig { */ @Bean(name = "filterMultipartResolver") public CommonsMultipartResolver filterMultipartResolver() { - CommonsMultipartResolver resolver = new CommonsMultipartResolver(); + CommonsMultipartResolver resolver = new CommonsMultipartResolver() { + @Override + protected FileUpload newFileUpload(FileItemFactory fileItemFactory) { + FileUpload fileUpload = super.newFileUpload(fileItemFactory); + fileUpload.setPartHeaderSizeMax(PART_HEADER_SIZE_MAX); + return fileUpload; + } + }; long maxSizeBytes = portalProperties.getFile().getMaxSizeBytes(); resolver.setMaxUploadSize(maxSizeBytes); @@ -39,8 +60,8 @@ public class MultipartConfig { resolver.setMaxInMemorySize((int) Math.min(maxSizeBytes, Integer.MAX_VALUE)); resolver.setDefaultEncoding("UTF-8"); - log.info("CommonsMultipartResolver configured with maxUploadSize: {} bytes ({})", - maxSizeBytes, portalProperties.getFile().getMaxSize()); + log.info("CommonsMultipartResolver configured with maxUploadSize: {} bytes ({}), partHeaderSizeMax: {} bytes", + maxSizeBytes, portalProperties.getFile().getMaxSize(), PART_HEADER_SIZE_MAX); return resolver; } diff --git a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java index cb5f2ce..341d54a 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java @@ -7,6 +7,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.ObjectPostProcessor; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -16,6 +17,7 @@ import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandlerImpl; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.csrf.CsrfException; +import org.springframework.security.web.header.HeaderWriterFilter; import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository; import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @@ -92,6 +94,23 @@ public class PortalConfigSecurity { http .authenticationManager(portalAuthenticationManager) + // CVE-2026-22732 우회책. Spring Security 는 기본적으로 보안 헤더를 "응답 커밋 시점"에 + // 지연 기록하는데, 응답 래퍼가 Content-Length 를 setHeader/setIntHeader/addIntHeader 로 + // 지정하는 경로를 추적하지 못해 그 경우 헤더가 통째로 누락된다 + // (X-Content-Type-Options, X-Frame-Options, Cache-Control, Pragma, Expires, X-XSS-Protection). + // 수정본은 5.7.22/5.8.24(Enterprise 전용)뿐이라 OSS 로는 올릴 수 없어 우회책을 적용한다. + // 요청 시작 시점에 헤더를 기록하게 만든다. 앱이 나중에 같은 헤더를 지정하면 앱 값이 남는다 + // (실측: FileDownloadController#viewImage 의 Cache-Control: public, max-age=86400 유지됨). + // 현재 앱은 response.setContentLength(int) 만 쓰므로 노출 경로는 없지만, + // 새 코드가 위 메서드를 쓰더라도 헤더가 빠지지 않도록 두는 안전망이다. + // Spring Security 를 수정본(6.5.9+/7.0.4+ 또는 Enterprise 5.7.22+)으로 올리면 제거 가능. + .headers(headers -> headers.addObjectPostProcessor(new ObjectPostProcessor() { + @Override + public O postProcess(O filter) { + filter.setShouldWriteHeadersEagerly(true); + return filter; + } + })) .formLogin(form -> form .loginPage("/login") .usernameParameter("id") diff --git a/src/main/resources/static/js/ChartEditor.js b/src/main/resources/static/js/ChartEditor.js deleted file mode 100644 index 26d0f20..0000000 --- a/src/main/resources/static/js/ChartEditor.js +++ /dev/null @@ -1,491 +0,0 @@ -class ChartEditor { - constructor(container, layoutManager, dashboard) { - this.rootContainer = container; - this.layoutManager = layoutManager; - this.dashboard = dashboard; - this.targetContainerIndex = -1; - this.targetColumnIndex = -1; - this.tempChart = null; - this.chartPreview = null; - this.containerHeight = 0; - - this.chartEditorModel = [ - { - 'groupName': 'default', - 'groupTitle': '기본 정보', - 'fields': [ - { - 'path': 'contentId', - 'type': 'text', - 'label': 'Chart ID', - 'placeholder': 'chartId를 입력하세요' - }, - { - 'path': 'styles.width', - 'type': 'text', - 'label': '길이(px, %)', - 'placeholder': 'px, % 등 단위를 포함한 길이를 입력하세요' - }, - { - 'path': 'styles.height', - 'type': 'text', - 'label': '높이(px, %)', - 'placeholder': 'px, % 등 단위를 포함한 높이를 입력하세요' - }, - { - 'path': 'config.animation', - 'type': 'boolean', - 'label': 'Animation 사용' - } - ] - }, - { - 'groupName': 'title', - 'groupTitle': '제목', - 'fields': [ - { - 'path': 'config.title.text', - 'type': 'text', - 'label': '차트 제목', - 'placeholder': '차트 제목을 입력하세요' - } - ] - }, - { - 'groupName': 'tooltip', - 'groupTitle': 'Tooltip', - 'fields': [ - { - 'path': 'config.tooltip.show', - 'type': 'boolean', - 'label': 'Tooltip 표시' - }, - { - 'path': 'config.tooltip.trigger', - 'type': 'select', - 'label': 'Tooltip Trigger', - 'placeholder': 'Enter the trigger type', - 'options': ['item', 'axis', 'none'] - } - ] - }, - { - 'groupName': 'legend', - 'groupTitle': 'Legend', - 'fields': [ - { - 'path': 'config.legend.show', - 'type': 'boolean', - 'label': 'Legend 표시' - } - ] - }, - { - 'groupName': 'grid', - 'groupTitle': 'Grid', - 'fields': [ - { - 'path': 'config.grid.show', - 'type': 'boolean', - 'label': 'Grid 표시' - }, - { - 'path': 'config.grid.left', - 'type': 'text', - 'label': 'Grid Left', - 'placeholder': '좌측 여백' - }, - { - 'path': 'config.grid.right', - 'type': 'text', - 'label': 'Grid Right', - 'placeholder': '우측 여백' - }, - { - 'path': 'config.grid.top', - 'type': 'text', - 'label': 'Grid Top', - 'placeholder': '위쪽 여백' - }, - { - 'path': 'config.grid.bottom', - 'type': 'text', - 'label': 'Grid Bottom', - 'placeholder': '아래쪽 여백' - }, - { - 'path': 'config.grid.containLabel', - 'type': 'boolean', - 'label': 'Contain Label' - } - ] - }, - { - 'groupName': 'xAxis', - 'groupTitle': 'X축', - 'fields': [ - { - 'path': 'config.xAxis.show', - 'type': 'boolean', - 'label': 'X축 표시' - }, - { - 'path': 'config.xAxis.type', - 'type': 'select', - 'label': 'X축 유형', - 'placeholder': 'X축 유형을 선택하세요', - 'options': ['category', 'value', 'time'] - } - ] - }, - { - 'groupName': 'yAxis', - 'groupTitle': 'Y축', - 'fields': [ - { - 'path': 'config.yAxis[0].show', - 'type': 'boolean', - 'label': 'Y축 표시' - }, - { - 'path': 'config.yAxis[0].name', - 'type': 'text', - 'label': 'Y축 이름' - }, - { - 'path': 'config.yAxis[0].type', - 'type': 'select', - 'label': 'Y축 유형', - 'placeholder': 'Y축 유형을 선택하세요', - 'options': ['category', 'value', 'time'] - }, - { - 'path': 'config.yAxis[0].position', - 'type': 'select', - 'label': 'Y축 위치', - 'placeholder': 'Y축 위치를 선택하세요', - 'options': ['left', 'right'] - } - ] - }, - { - 'groupName': 'toolbox', - 'groupTitle': 'Toolbox', - 'fields': [ - { - 'path': 'config.toolbox.show', - 'type': 'boolean', - 'label': 'Toolbox 표시', - 'placeholder': 'Check to show toolbox' - }, - { - 'path': 'config.toolbox.feature.saveAsImage.show', - 'type': 'boolean', - 'label': 'Save as Image 사용' - } - ] - } - ]; - this.seriesConfigModel = [ - { - 'groupName': 'default', - 'groupTitle': '시리즈 정보', - 'fields': [ - { - 'path': 'seriesConfig[0].queryType', - 'type': 'select', - 'label': 'Query Type', - 'options': ['elasticsearch'] - }, - // { - // 'path': 'seriesConfig[0].type', - // 'type': 'select', - // 'label': '차트 유형', - // 'placeholder': '차트 유형을 선택하세요', - // 'options': ['bar', 'line', 'pie'] - // }, - { - 'path': 'seriesConfig[0].index', - 'type': 'text', - 'label': 'Elasticsearch Index', - 'placeholder': 'Elasticsearch Index를 입력하세요' - }, - { - 'path': 'seriesConfig[0].query', - 'type': 'textarea', - 'label': 'Elasticsearch Query' - }, - { - 'path': 'seriesConfig[0].queryResult', - 'type': 'textarea', - 'label': 'Elasticsearch Query Result' - }, - { - 'path': 'seriesConfig[0].dataPath', - 'type': 'text', - 'label': '데이터 경로', - 'placeholder': '데이터 경로를 입력하세요' - } - ] - } - ]; - } - - initChartEditor() { - - const chartEditorPopup = ` - `; - - $(this.rootContainer).parent().append(chartEditorPopup); - - //add event to modal show - $('#chart_editor_modal').on('shown.bs.modal', (event) => { - let chartContainer = document.getElementById('chart-preview'); - let rect = chartContainer.getBoundingClientRect(); - let width = rect.width; // Precise width - let height = rect.height; // Precise height - this.chartPreview = echarts.init(document.getElementById('chart-preview'), 'dark', { - width: width, height: height - }); - this.drawCharts(); - }); - } - - showChartEditor(isNew, tempChart, containerIndex, columnIndex) { - this.targetContainerIndex = containerIndex; - this.targetColumnIndex = columnIndex; - this.tempChart = tempChart; - if (isNew) { - $('.add-chart').show(); - $('.save-chart').hide(); - } else { - $('.add-chart').hide(); - $('.save-chart').show(); - } - - let flatModel = LayoutUtil.flattenObject(tempChart); - - $('#chart_editor_modal').find('input, select, textarea').each((index, element) => { - if ($(element).attr('type') === 'checkbox') { - $(element).prop('checked', false); - } - $(element).val(''); - }); - - _.forEach(flatModel, (value, key) => { - - const inputSelector = `input[name="${key}"]`; - const selectSelector = `select[name="${key}"]`; - const textareaSelector = `textarea[name="${key}"]`; - - const $input = $('#chart_editor_modal').find(inputSelector); - const $select = $('#chart_editor_modal').find(selectSelector); - const $textarea = $('#chart_editor_modal').find(textareaSelector); - - if ($input.length > 0 && $input.attr('type') === 'checkbox') { - // If it's a checkbox, update its 'checked' property - $input.prop('checked', value); - } else if ($input.length > 0 || $select.length > 0 || $textarea.length > 0) { - // If any input, select, or textarea is found, update its value - $($input.add($select).add($textarea)).val(value); - } else { - // If no elements are found, log to the console - console.log(`Element not found for name: ${key}`); - } - }); - - $('#chart_editor_modal').modal('show'); - console.log('chart_editor_modal show'); - - this.bindEvents(); - } - - addChart() { - $('#chart_editor_modal').find('input, select, textarea').each((index, element) => { - const fieldName = $(element).attr('name'); - const newValue = LayoutUtil.getFieldValue(element); - if (fieldName !== '') { - _.set(this.tempChart, fieldName, newValue); - } - }); - this.layoutManager.layoutData.children[this.targetContainerIndex].children.push(JSON.parse(JSON.stringify(this.tempChart))); - this.tempChart = null; - this.targetContainerIndex = -1; - this.targetColumnIndex = -1; - - this.layoutManager.resetLayout(); - $('#chart_editor_modal').modal('hide'); - } - - cleanUpEvents() { - console.log('cleanUpEvents'); - $('.add-chart').off('click'); - $('.save-chart').off('click'); - } - - saveChart() { - console.log('saveChart'); - - $('#chart_editor_modal').find('input, select, textarea').each((index, element) => { - const fieldName = $(element).attr('name'); - const newValue = LayoutUtil.getFieldValue(element); - if (fieldName !== undefined && fieldName !== '') { - _.set(this.tempChart, fieldName, newValue); - } - }); - const json = JSON.stringify(this.tempChart, LayoutUtil.echartReplacer, 2); - console.log(json); - this.layoutManager.layoutData.children[this.targetContainerIndex].children[this.targetColumnIndex] = JSON.parse(json); - this.targetContainerIndex = -1; - this.targetColumnIndex = -1; - this.layoutManager.resetLayout(); - $('#chart_editor_modal').modal('hide'); - } - - refreshChart(chartInfo) { - console.log('refreshChart'); - - return new Promise((resolve, reject) => { - let option = JSON.parse(JSON.stringify(chartInfo.config)); - option.series = []; - - let self = this; - Promise.all(this.tempChart.seriesConfig.map(config => this.dashboard.requestQuery(config))).then(async (results) => { - await self.dashboard.updateChartSeries(option, results, chartInfo, (index, result) => { - $('#chart_editor_modal').find(`textarea[name='seriesConfig[${index}].queryResult']`).val('result = ' + JSON.stringify(result, null, 2)); - }); - - document.getElementById('echartOption').value = JSON.stringify(option, null, 2); - self.chartPreview.setOption(option); - resolve(); - }).catch((error) => { - console.error('Error fetching data for preview: ', error); - reject(error); - }); - - }); - } - - drawCharts() { - console.log('drawCharts'); - - const promises = [this.refreshChart(this.tempChart)]; - - Promise.all(promises).then(() => console.log('Chart Preview is completed')).catch(error => console.error('An error occurred:', error)); - } - - bindEvents() { - $('.add-chart').off('click').on('click', (event) => { - this.addChart(); - }); - - $('.save-chart').off('click').on('click', (event) => { - this.saveChart(); - }); - - $('#chart_editor_modal').find('input, select, textarea').each((index, element) => { - $(element).off('change').on('change', (event) => { - console.log('change event'); - const fieldName = $(event.currentTarget).attr('name'); - const newValue = LayoutUtil.getFieldValue(event.currentTarget); - if (fieldName !== '') { - _.set(this.tempChart, fieldName, newValue); - } - this.refreshChart(this.tempChart); - }); - }); - - $('#chart_editor_modal').on('hidden.bs.modal', (event) => { - if (this.chartPreview != null && this.chartPreview.dispose) { - this.chartPreview.dispose(); - this.chartPreview = null; - } - - this.cleanUpEvents(); - }); - } -} diff --git a/src/main/resources/static/js/Dashboard.js b/src/main/resources/static/js/Dashboard.js deleted file mode 100644 index 43f6235..0000000 --- a/src/main/resources/static/js/Dashboard.js +++ /dev/null @@ -1,344 +0,0 @@ -class Dashboard { - - constructor(layoutData) { - this.layoutData = layoutData || {}; - this.fromDateTarget = 'fromDate'; - this.toDateTarget = 'toDate'; - this.charts = []; - this.theme = 'dark'; - this.requestUrl = '/mgmt/dashboard/search.do'; - - } - - bindEvents() { - const events = [{selector: '.refresh', action: this.refresh.bind(this)}]; - - for (let event of events) { - $(document).off('click', event.selector).on('click', event.selector, event.action); - } - } - - getDateTemplate(id, label) { - return ` -
-
- - -
- - - -
- `; - } - - init(chartAreaId) { - console.log('Dashboard init'); - this.chartAreaId = chartAreaId; - this.initDate(); - this.initCharts(); - this.drawCharts(); - - window.addEventListener('resize', this.resizeAllCharts.bind(this)); - } - - initCharts() { - console.log('initCharts'); - this.charts = []; - if (this.layoutData.children) { - this.layoutData.children.forEach((row) => { - row.children.forEach((chartInfo) => { - this.charts.push(chartInfo); - }); - }); - } - } - - formatDate(isoString) { - const date = new Date(isoString); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); // +1 because getMonth() returns 0-11 - const day = String(date.getDate()).padStart(2, '0'); - - return `${year}-${month}-${day}`; - } - - formatTime(isoString) { - const date = new Date(isoString); - const hour = String(date.getHours()).padStart(2, '0'); - const minute = String(date.getMinutes()).padStart(2, '0'); - return `${hour}:${minute}:00`; - } - - formatDateTime(isoString) { - return this.formatDate(isoString) + 'T' + this.formatTime(isoString); - } - - startOfDay(isoString) { - return this.formatDate(isoString) + 'T00:00:00'; - } - - initDate() { - const ko = tempusDominus.locales.ko.localization; - const fromDateElement = document.getElementById(this.fromDateTarget); - $(fromDateElement).data('td-target-input', 'nearest'); - $(fromDateElement).data('td-target-toggle', 'nearest'); - - const toDateElement = document.getElementById(this.toDateTarget); - $(toDateElement).data('td-target-input', 'nearest'); - $(toDateElement).data('td-target-toggle', 'nearest'); - - $(fromDateElement).append(this.getDateTemplate(this.fromDateTarget, 'From')); - $(toDateElement).append(this.getDateTemplate(this.toDateTarget, 'To')); - - this.fromDate = new tempusDominus.TempusDominus(fromDateElement, { - localization: ko, defaultDate: this.startOfDay(new Date()) - }); - this.toDate = new tempusDominus.TempusDominus(toDateElement, { - useCurrent: false, localization: ko, defaultDate: new Date() - }); - - fromDateElement.addEventListener(tempusDominus.Namespace.events.change, (e) => { - this.toDate.updateOptions({ - restrictions: { - minDate: e.detail.date - } - }); - }); - - const subscription = this.toDate.subscribe(tempusDominus.Namespace.events.change, (e) => { - this.fromDate.updateOptions({ - restrictions: { - maxDate: e.date - } - }); - }); - } - - requestPost(url, data, hds) { - var deferred = $.Deferred(); - - var headers = $.extend({ - 'Content-Type': 'application/json; charset=utf-8' - }, hds); - - if (headers['Content-Type'].startsWith('application/json')) { - data = JSON.stringify(data); - } - - $.ajax({ - url: url, async: true, method: 'POST', data: data, headers: headers - }).done((response) => { - deferred.resolve(response); - }).fail((response, textStatus, errorThrown) => { - deferred.reject(response, textStatus, errorThrown); - }); - return deferred.promise(); - }; - - requestQuery(seriesConfig) { - let request = null; - try { - if (seriesConfig.queryType === 'elasticsearch') { - if (seriesConfig.query && seriesConfig.index !== '') { - let requestQuery = JSON.parse(seriesConfig.query); - requestQuery.query = requestQuery.query || {}; - requestQuery.query.bool = requestQuery.query.bool || {}; - requestQuery.query.bool.filter = requestQuery.query.bool.filter || []; - requestQuery.query.bool.filter.push({ - 'range': { - '@timestamp': { - 'gte': this.formatDateTime(this.fromDate.dates.picked[0]), - 'lte': this.formatDateTime(this.toDate.dates.picked[0]), - 'time_zone': 'Asia/Seoul' - } - } - }); - - request = { - query: JSON.stringify(requestQuery), index: seriesConfig.index - }; - return this.requestPost(this.requestUrl, request, {}); - } else { - console.log('query is empty'); - return Promise.resolve(); - } - } - } catch (error) { - console.error('Error fetching data for ' + seriesConfig.contentId + ': ', error); - return Promise.reject(error); - } - } - - resizeAllCharts() { - this.charts.forEach(chartInfo => { - let chartContainer = document.getElementById('chart_' + chartInfo.contentId); - let rect = chartContainer.getBoundingClientRect(); - let width = rect.width; // Precise width - let height = rect.height; // Precise height - if (chartInfo.echart) { - chartInfo.echart.resize({width: width, height: height}); - } - }); - } - - initializeChart(chartInfo) { - console.log('initializeChart: ' + chartInfo.contentId); - let chartContainer = document.getElementById('chart_' + chartInfo.contentId); - let rect = chartContainer.getBoundingClientRect(); - let width = rect.width; // Precise width - let height = rect.height; // Precise height - - chartInfo.echart = echarts.init(document.getElementById('chart_' + chartInfo.contentId), this.theme, { - width: width, height: height - }); - } - - executeScript(result, script) { - console.log('executeScript'); - if (script) { - return new Promise((resolve, reject) => { - let resultFrame = document.getElementById('mapperScript'); - window.preRequestComplete = (result, error) => { - if (error) { - console.log('Error executing script: ', error); - reject(error); - } else { - resolve(result); - } - }; - window.result = result; - resultFrame.srcdoc = ``; - }); - } else { - console.log('No script to execute'); - return Promise.resolve(); - } - - } - - async refresh(event) { - console.log('refresh'); - - for (let i = 0; i < this.charts.length; i++) { - const chartInfo = this.charts[i]; - try { - await this.refreshChart(chartInfo); - } catch (error) { - console.error(`Error refreshing chart ${chartInfo.contentId}:`, error); - } - } - console.log('All Charts requests are complete'); - // const promises = this.charts.map(info => this.refreshChart(info)); - // Promise.all(promises).then(() => console.log('All Charts requests are complete')).catch(error => console.error('An error occurred:', error)); - } - - drawCharts() { - console.log('drawCharts'); - this.charts.forEach(info => this.initializeChart(info)); - - this.refresh(); - this.bindEvents(); - } - - async updateChartSeries(option, results, chartInfo, callback) { - console.log('updateChartSeries'); - for (let i = 0; i < results.length; i++) { - const result = results[i]; - if (result){ - let config = chartInfo.seriesConfig[i]; - let parsedResult = JSON.parse(result); - - if (callback) { - callback(i, parsedResult); - } - try { - console.log('start executeScript'); - const dataY = await this.executeScript(parsedResult, config.mapperY); - - if (Array.isArray(dataY)) { - dataY.forEach(item => { - option.series.push(item); - }); - } else { - option.series.push(dataY); - } - const dataX = await this.executeScript(parsedResult, config.mapperX); - option.xAxis.data = dataX; - - console.log('stop executeScript'); - } catch (error) { - console.error(`Error updating series for chart ${chartInfo.contentId}:`, error); - } - } - } - } - - async refreshChart(chartInfo) { - - try { - console.log('refreshChart'); - - let option = JSON.parse(JSON.stringify(chartInfo.config)); - option.series = []; - - const results = await Promise.all(chartInfo.seriesConfig.map(config => this.requestQuery(config))); - await this.updateChartSeries(option, results, chartInfo); - - // Clear the chart and set the new option - chartInfo.echart.clear(); - chartInfo.echart.setOption(option); - - console.log('Chart refreshed successfully'); - - } catch (error) { - console.error('Error fetching data for ' + chartInfo.contentId + ': ', error); - throw error; // Rethrow the error if you need to catch it higher up in the call stack - } - } - -} diff --git a/src/main/resources/static/js/LayoutManager.js b/src/main/resources/static/js/LayoutManager.js deleted file mode 100644 index 0634f88..0000000 --- a/src/main/resources/static/js/LayoutManager.js +++ /dev/null @@ -1,643 +0,0 @@ -class LayoutManager { - constructor(container, layoutData) { - this.rootContainer = container; - this.tempContainer = null; - this.editContainerIndex = -1; - this.layoutData = layoutData || { - type: 'root', - styles: { - 'display': 'flex', - 'flex-direction': 'column', - 'justify-content': 'start', - 'align-items': 'flex-start', - 'gap': '10px', - 'width': '100%', - 'height': '100vh', - 'position': 'relative', - 'overflow': 'auto', - 'background-color': '#100C2A' - }, - children: [] - }; - - this.containerEditorModel = [ - { - 'groupName': 'default', - 'groupTitle': '기본', - 'fields': [ - { - 'path': 'styles.display', - 'type': 'select', - 'label': 'Display', - 'options': ['flex'] - }, - { - 'path': 'styles.flex-direction', - 'type': 'select', - 'label': 'Flex 방향', - 'options': ['row', 'column', 'row-reverse', 'column-reverse'] - }, - { - 'path': 'styles.width', - 'type': 'text', - 'label': '길이(px, %)', - 'placeholder': 'px, % 등 단위를 포함한 길이를 입력하세요' - }, - { - 'path': 'styles.height', - 'type': 'text', - 'label': '높이(px, %)', - 'placeholder': 'px, % 등 단위를 포함한 높이를 입력하세요' - } - ] - } - ]; - - this.dashboardEditorModel = [ - { - 'groupName': 'default', - 'groupTitle': '기본', - 'fields': [ - { - 'path': 'styles.background-color', - 'type': 'text', - 'label': '색상', - 'placeholder': '#로 시작하는 값을 입력하세요' - }, - { - 'path': 'styles.width', - 'type': 'text', - 'label': '길이(px)', - 'placeholder': 'px 단위를 포함한 길이를 입력하세요' - }, - { - 'path': 'styles.height', - 'type': 'text', - 'label': '높이(px)', - 'placeholder': 'px 단위를 포함한 높이를 입력하세요' - } - ] - } - ] - - } - - // traverse(obj) { - // if (obj.type === 'chart') { - // $(`#chart_${obj.contentId}`).closest('.dashboard-chart').resizable({ - // grid: 10, - // ghost: true, - // stop: (event, ui) => { - // const {width, height} = ui.size; - // obj.styles.width = `${width}px`; - // obj.styles.height = `${height}px`; - // this.resetLayout(); - // } - // }); - // } - // - // if (obj.children) { - // obj.children.forEach(child => { - // this.traverse(child); - // }); - // } - // } - - init(Dashboard, viewerMode) { - this.viewMode = viewerMode || false; - this.renderRoot(this.layoutData, this.rootContainer); - - if (Dashboard) { - this.dashboard = new Dashboard(this.layoutData); - this.dashboard.init('chart_area'); - } - - if (!this.viewMode) { - this.initContainerEditor(); - this.initDashboardEditor(); - this.chartEditor = new ChartEditor(this.rootContainer, this, this.dashboard); - this.chartEditor.initChartEditor(); - this.bindEvents(); - } - } - - initContainerEditor() { - const containerEditorPopup = ` -