37 Commits

Author SHA1 Message Date
Rinjae(gf63) 094336ebf1 - Gradle 실행 설정 추가 (clean, compileJava)
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- API 편집기 닫기 전 변경사항 알림/확인 로직 추가
- 저장 성공/초기 로드 시점 기준 스냅샷 로직 및 UI 처리를 반영
2026-09-11 18:38:50 +09:00
Rinjae a310b671e2 API 통계 날짜 선택 로직 및 테스트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 최대 40일 범위 제한 및 1년 조회 가능 기간 검증
- Date-range Picker UI 추가 및 서버 동기화 로직 구현
- Controller, DTO 유효성 테스트 및 Playwright 테스트 작성
2026-09-09 16:25:00 +09:00
Rinjae d9373b00f0 Moment.js 업그레이드 및 호환성 테스트 추가:
- 최신 버전(2.30.1)으로 업데이트 및 CVE-2022-24785 대응
- 브라우저 기반 date-range 동작 검증 및 Playwright 테스트 추가
- 윤년/서머타임 처리 및 CommonJS 보안 회귀 검증
2026-09-09 16:00:13 +09:00
Rinjae 09bc3a65f5 API Selector 전역변수 사용 수정: window → globalThis
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-09 14:36:55 +09:00
Rinjae 1d81681e70 API 목록 페이징 로직 테스트 및 페이지 크기 설정 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 기본 페이지 크기 설정 ApiListProperties 도입
- Controller, 템플릿, JS에 설정값 연결 및 테스트 추가
2026-09-09 12:02:08 +09:00
Rinjae d4bafa706c 로고 파일 이름 변경: logo-jjb_white.pnglogo-jjb-white.png
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-09 10:00:35 +09:00
Rinjae 60f9f89d90 2FA 채널 정렬 로직 추가: SMS를 우선 표시 및 기본 선택 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-09 09:51:44 +09:00
Rinjae 8ecfaf4945 쿠키 및 CSRF 토큰 유지 여부 검사 스크립트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 새로운 익명 세션에서 두 서버 간 응답 비교 기능 구현
- Python 기반 검사기 `cookie.py` 작성 및 주요 옵션 제공
2026-09-08 10:31:58 +09:00
Rinjae fa736a36ab Thymeleaf 호환성 테스트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Mvc/Form/Security/Expression 관련 템플릿 및 테스트 구현
- 의존성 검증 및 업그레이드 스크립트 추가 (Thymeleaf 3.1.5 적용)
2026-09-07 15:29:45 +09:00
Rinjae b7869ad6e6 OHS 설정 업데이트:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 정적 리소스 경로 `/apps/portal-static`으로 변경
- favicon 및 SetHandler 설정 추가
2026-09-07 11:04:20 +09:00
Rinjae 982187298c 정적 리소스 서빙 및 빌드 프로세스 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- OHS 환경 대응 정적자원 해시 버전닝 OFF 설정 반영
- eapim-portal-static.zip 생성 및 CI/CD 추가
- OHS 설정 샘플 가이드 추가
2026-09-07 10:55:43 +09:00
Rinjae 8118dba74d CSS 소스맵 업데이트: 최신 Sass 구성 반영 및 맵핑 갱신
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-03 17:05:47 +09:00
Rinjae-gf63 97489d0361 - PasswordDecryptFilter에 /internal/** 경로 제외 처리 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 내부 API 요청 필터 동작/암호화 정책 충돌 방지
2026-09-03 09:19:46 +09:00
Rinjae-gf63 924c00968c - 멀티파트 요청 지원 복호화 래퍼 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PasswordDecryptFilter 순서 조정(MultipartFilter 이후 실행)
- 필터와 XSS Escape 필터 순서 정보 및 주석 보완
2026-09-02 18:35:44 +09:00
Rinjae 53c3b0ee7e 포탈 암호화 모듈 추가: forge-crypto.min.js 라이브러리 도입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-02 16:59:25 +09:00
Rinjae b2787882bf 비밀번호 암호화 로직 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- name 재지정 및 hidden 필드로 봉투 전송 처리 추가
- 폼 제출 후 원상복구 스케줄링 로직 구현
2026-09-02 09:50:56 +09:00
Rinjae aafa7ac351 NotiTestController 요청 매핑 경로 수정: //notitest/djb/notitest
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-02 09:35:50 +09:00
Rinjae b7477e10a1 feats/security 브랜치 병합 - 비밀번호 전송 암호화(RSA-OAEP + AES-GCM) 도입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-01 15:08:21 +09:00
Rinjae 11f1dae1f3 Thymeleaf 프래그먼트 경로 표기 오류 수정:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 모든 th:replace/th:include 경로 ~{}로 변경
2026-09-01 15:07:23 +09:00
Rinjae 59c379b22c 비밀번호 전송 암호화 기능 추가:
- RSA-OAEP + AES-GCM 기반 비밀번호 전송 암호화 모듈 구현
- 클라이언트 공개키 발급 및 복호화 처리 로직 개발
- 암호화 설정 속성 및 요청별/세션별/서버별 키 보관소 구성
2026-09-01 15:06:30 +09:00
Rinjae d275aa12c4 Admin GatewayClient 다중 base URL 및 failover 로직 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- admin.base-url 다중 주소 지원 및 순차 failover 구현
- RestTemplate 타임아웃 설정 변경 (연결/읽기: 5초로 단축)
2026-09-01 14:50:39 +09:00
Rinjae c732f1e1b2 API 상태 타임라인 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 진행 상태에 따른 상태 색 및 한글 라벨 표시 추가
2026-09-01 14:12:17 +09:00
Rinjae 9e32502ee3 WebLogic 라이브러리 안정성 검증 로직 강화:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- jboss-logging/Guava 클래스 로드시충돌 점검 추가
- ReadinessController에 관련 체크 로직 반영
2026-08-27 16:47:15 +09:00
Rinjae 2d0ff2bf84 템플릿 엔진 상태 점검 추가 - Thymeleaf 구성 검증 로직 반영
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 16:44:57 +09:00
Rinjae 21940a3e18 WebLogic jackson 버전 충돌 방지 설정 추가 - jackon-module-jaxb-annotations 명시 의존성 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 16:40:05 +09:00
Rinjae 2e517a1e08 WebLogic 라이브러리 4개 우선 로드 설정 추가 - IllegalAccessError/NoSuchMethodError 방지
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 16:25:30 +09:00
Rinjae 10c5e51162 WebLogic commons-lang3 우선 로드 설정 추가 - IllegalAccessError 방지
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 16:20:24 +09:00
Rinjae 69e5126de6 WebLogic commons-fileupload 우선 로드 설정 추가 - NoSuchMethodError 방지
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 15:55:37 +09:00
Rinjae d044ebd157 Merge branch 'feats/security'
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 15:43:42 +09:00
Rinjae d6b3a44d61 Merge branch 'master' into feats/security
# Conflicts:
#	src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookRegistrationDTO.java
#	src/main/resources/templates/views/apps/webhook/webhookList.html
#	src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html
#	src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html
2026-08-27 13:23:15 +09:00
Rinjae e57f2fe72f WIP: webhook 작업 임시 커밋 (develop 전환 전 안전 확보) 2026-08-27 10:59:22 +09:00
Rinjae dfb37c18b9 commons-lang3 3.13 → 3.13.1 업그레이드와 코드 최적화
- Strings API로 대체 및 StringUtils 불필요 메서드 제거
- CellPhoneValidator 시작 번호 체크 로직 개선
2026-08-18 17:48:58 +09:00
Rinjae 5f5b23026f JSON 및 파일 처리 라이브러리 상향 회귀 테스트 추가
- commons-lang3 3.20.0 업그레이드로 문자열 처리 유지 검증
- 파트 헤더 설정 증가(CVE-2025-48976) 회귀 확인 테스트 추가
- jackson-bom 2.18.10 업그레이드 후 날짜 직렬화 검증
2026-08-18 17:07:21 +09:00
Rinjae 5c61fc3f24 멀티파트 업로드 및 의존성 보안 업데이트
- MultipartConfig: partHeaderSizeMax 설정 추가(CVE-2025-48976 대응)
- build.gradle: commons-fileupload 1.6.0 및 commons-beanutils 1.11.0 업그레이드
2026-08-18 16:56:26 +09:00
Rinjae a7aa5bc98c Spring Boot/Framework/Security 관련 CVE 억제 규칙 추가 및 구성 수정
- dependency-check-suppressions.xml: 오탐 및 비사용 기능 CVE 5건 억제 추가
- PortalConfigSecurity.java: CVE-2026-22732 대응 우회 로직 추가
- build.gradle: Spring 의존성 버전 정리 및 중복 핀 제거
2026-08-18 15:18:08 +09:00
Rinjae bf63701e68 리다이렉트 경로 상수화 및 중복 제거
- "redirect:/", "redirect:/login?reason=auth" 상수로 치환
- 중복된 문자열 정의 제거 및 가독성 개선
2026-08-18 14:50:08 +09:00
Rinjae f26aae296a ECharts 테마 파일 및 ChartEditor.js 제거 - 불필요 리소스 삭제 2026-08-18 14:44:06 +09:00
163 changed files with 5625 additions and 12228 deletions
@@ -0,0 +1,28 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="eapim-portal [clean compileJava]" type="GradleRunConfiguration" factoryName="Gradle" nameIsGenerated="true">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="--no-build-cache -x test" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="clean" />
<option value="compileJava" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<GradleProfilingDisabled>false</GradleProfilingDisabled>
<GradleCoverageDisabled>false</GradleCoverageDisabled>
<method v="2" />
</configuration>
</component>
+2 -1
View File
@@ -74,8 +74,9 @@ pipeline {
set -eu
cd build/libs
sha256sum eapim-portal.war > eapim-portal.war.sha256
sha256sum eapim-portal-static.zip > eapim-portal-static.zip.sha256
'''
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256', fingerprint: true
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal-static.zip,build/libs/eapim-portal-static.zip.sha256', fingerprint: true
stash name: 'war', includes: 'build/libs/eapim-portal.war'
}
}
+2 -1
View File
@@ -96,8 +96,9 @@ pipeline {
sha256sum "$f" > "$f.sha256"
md5sum "$f" > "$f.md5"
done
sha256sum eapim-portal-static.zip > eapim-portal-static.zip.sha256
'''
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5,build/libs/eapim-portal-static.zip,build/libs/eapim-portal-static.zip.sha256', fingerprint: true
}
}
}
+91 -11
View File
@@ -83,7 +83,14 @@ 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'
}
// WW-5417 관련 public 필드 접근 권한 검사 누락 수정(OGNL #264/#265).
// 3.3.x EOL 계열의 단기 조치. 3.4.x는 Thymeleaf 3.1.5의 OgnlContext 생성자와 비호환.
// 일반 OGNL 경로도 ThymeleafExpressionCompatibilityTest로 검증한다(Spring EL만으로는 확인 불가).
implementation 'ognl:ognl:3.3.5'
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 +98,48 @@ 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'
// weblogic.xml prefer-application-packages 가 com.fasterxml.jackson.* 를 앱 우선으로 강제한다
// (oracle_common jackson-databind 2.17.0 vs 앱 2.18.10 split-package 방지, WLog 실사용 확인).
// Thymeleaf StandardJavaScriptSerializer 가 ObjectMapper.findAndRegisterModules() 로
// jackson-module-jaxb-annotations 를 옵션 모듈로 자동탐색하는데, 이 모듈은 WebLogic 번들에만
// 있고 앱엔 없어 ServiceConfigurationError(Provider not found) 발생 → 앱에도 명시 추가해
// jackson 전체를 자체 완결시킨다(jackson-bom 2.18.10 로 버전 통일).
implementation 'com.fasterxml.jackson.module:jackson-module-jaxb-annotations'
// 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 +149,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 +170,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')
// CVE-2026-41901: ≤3.1.4의 제한된 표현식 구문 검사 우회(SSTI)를 3.1.5에서 수정.
// Boot 의존성 관리로 core/spring5를 함께 맞추며 Java8 / Spring5 / javax.servlet을 유지한다.
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0은 유지.
// ThymeleafBootMvcCompatibilityTest가 Boot 2.7 자동 구성 엔진·ViewResolver의 초기화,
// MVC 폼·레이아웃·보안 표시를 검증한다. 실제 WAS 기동/재배포 검증은 별도 배포 조건이다.
// 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException).
set('thymeleaf.version', '3.1.5.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')
}
@@ -251,6 +319,18 @@ war {
classpath = excludeLocalOnlyLibs(classpath)
}
// 정적 리소스만 별도 zip (OHS 추가 배포용). war/bootWar 는 그대로 정적 리소스 포함 유지
// (WAS 단독 접속 인원 지원). sass/ 는 컴파일 소스이며 실제 서빙 경로에 없어 제외
// (PortalConfigWebDispatcherServlet#addResourceHandlers 기준).
task staticResourcesZip(type: Zip) {
archiveFileName = "eapim-portal-static.zip"
destinationDirectory = file("$buildDir/libs")
from('src/main/resources/static') {
exclude 'sass/**'
}
}
assemble.dependsOn staticResourcesZip
task printSourceSets {
doLast {
sourceSets.each { srcSet ->
+90
View File
@@ -28,4 +28,94 @@
</suppress>
-->
<!-- ================================================================================
Spring 계열 Critical 5건 (2026-08-18 리포트 기준).
공통 배경: Spring Framework 5.3.x / Spring Boot 2.7.x / Spring Security 5.7.x 는 모두 OSS EOL 이라
남은 수정본이 상용(Enterprise/Tanzu)뿐이다. JDK 8 유지 제약상 Boot 3 + Spring 6 이관 전까지는
버전 상향으로 못 없앤다. 아래는 "코드에 트리거 경로가 없음"을 근거로 한 억제이며 만료일을 둔다.
버전 범위를 5.3.x / 2.7.x / 5.7.x 로 고정해 두었으므로, 이관 후에는 억제가 자동으로 풀린다.
재검토 시 확인할 것: (1) 각 근거 grep 이 여전히 0건인지 (2) OSS 수정본이 나왔는지.
================================================================================ -->
<!-- 1. Spring Boot: Cassandra SSL 호스트명 미검증 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2026-40974 는 Spring Boot 의 Cassandra SSL 자동설정이 SSL 번들의 호스트명 검증 설정을
드라이버에 전달하지 않는 문제다. 이 앱은 Cassandra 를 쓰지 않는다
(runtimeClasspath 에 cassandra/datastax 계열 jar 0건, spring-boot-starter-data-cassandra 미선언).
수정본 2.7.33 은 상용(Enterprise) 릴리스라 OSS 로는 올릴 수 없다.
devtools/actuator/spring-boot-admin jar 는 스캔 대상(runtimeClasspath)에는 있으나
배포 산출물에서는 build.gradle 의 localOnlyLibPrefixes 로 제외되어 WAR 에 실리지 않는다.
확인: Rinjae / 2026-08-18 / grep -ri cassandra 0건, WAR 내 devtools/actuator jar 0건.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/.*@2\.7\..*$</packageUrl>
<cve>CVE-2026-40974</cve>
</suppress>
<!-- 2. Thymeleaf SSTI 2건: starter jar 이름 기준 오탐 -->
<suppress>
<notes><![CDATA[
오탐. CVE-2026-40477 / CVE-2026-40478 은 thymeleaf 본체 3.1.3.RELEASE 이하의 표현식 샌드박스
우회 문제이고 3.1.4.RELEASE 에서 수정됐다. 이 프로젝트는 build.gradle 의 ext 에서
thymeleaf.version=3.1.4.RELEASE / thymeleaf-extras-springsecurity.version=3.1.5.RELEASE 로
올려 두었으므로 실제 실리는 jar 는 thymeleaf-3.1.4.RELEASE.jar 다.
Dependency-Check 가 spring-boot-starter-thymeleaf-2.7.18.jar(의존만 선언한 빈 starter)에
thymeleaf CPE 를 잘못 매칭한 결과다. thymeleaf 본체 jar 에 대한 탐지는 억제하지 않는다.
확인: Rinjae / 2026-08-18 / WAR 내 thymeleaf-3.1.4.RELEASE.jar, thymeleaf-spring5-3.1.4.RELEASE.jar.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/spring-boot-starter-thymeleaf@.*$</packageUrl>
<cve>CVE-2026-40477</cve>
<cve>CVE-2026-40478</cve>
</suppress>
<!-- 3. Spring Framework: JMS Jackson 역직렬화 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2026-41855 는 org.springframework.jms.support.converter.MappingJackson2MessageConverter /
JacksonJsonMessageConverter 가 임의 클래스 인스턴스화를 허용하는 문제다(신뢰할 수 없는 JMS 환경 전제).
이 앱은 JMS 를 쓰지 않는다: runtimeClasspath 에 spring-jms 0건, 소스에 javax.jms /
JmsTemplate / MappingJackson2MessageConverter 참조 0건.
5.3.x 는 OSS 수정본이 없다(6.2.19 / 7.0.8 에서만 수정). 이미 5.3.x OSS 마지막인 5.3.39 로 올려 둔 상태다.
확인: Rinjae / 2026-08-18 / grep -rn "javax.jms|JmsTemplate|MappingJackson2MessageConverter" src 0건.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework/spring-.*@5\.3\..*$</packageUrl>
<cve>CVE-2026-41855</cve>
</suppress>
<!-- 4. spring-web: HttpInvoker 역직렬화 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2016-1000027 은 HttpInvokerServiceExporter 를 노출했을 때만 성립한다. 해당 클래스는
Spring 6.0 에서 제거됐고 5.3.x 에는 수정본이 없다(= 5.3.x 를 쓰는 한 계속 탐지된다).
이 앱은 HttpInvoker 계열을 쓰지 않는다: 소스에 HttpInvoker 참조 0건이고 외부 호출은
RestTemplate / HttpClient 기반이다.
근본 해결은 Spring 6(Boot 3, JDK 17) 이관. 이관 전까지 억제한다.
확인: Rinjae / 2026-08-18 / grep -rn HttpInvoker src 0건.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework/spring-web@5\.3\..*$</packageUrl>
<cve>CVE-2016-1000027</cve>
</suppress>
<!-- 5. Spring Security: 응답 커밋 시 보안 헤더 미기록 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2026-22732 는 응답이 커밋된 뒤 Spring Security 가 보안 헤더를 기록하지 못하는 문제다.
트리거는 Content-Length 를 setHeader / setIntHeader / addIntHeader 로 지정하는 경로다
(OnCommittedResponseWrapper 는 setContentLength / setContentLengthLong / addHeader 만 추적한다).
(1) 이 코드베이스에는 트리거가 없다: portal / elink-portal-common / elink-online-core-jpa 전체에
setIntHeader, addIntHeader, setHeader("Content-Length") 0건. 파일 다운로드는
response.setContentLength(int) 를 쓰며 실측상 보안 헤더가 정상 기록된다.
(2) 그럼에도 안전망으로 PortalConfigSecurity 에서
HeaderWriterFilter.setShouldWriteHeadersEagerly(true) 를 적용해(spring.io 권고 우회책)
요청 시작 시점에 헤더를 기록하도록 했다.
수정본 5.7.22 / 5.8.24 는 Enterprise 전용이라 OSS 로는 올릴 수 없다. 이미 5.7.x OSS 마지막인
5.7.14 로 올려 둔 상태다.
확인: Rinjae / 2026-08-18 / 동일 스택(Boot 2.7.18 + Security 5.7.14 + Tomcat 9.0.120) 프로브 앱 실측 —
우회책 미적용 시 setHeader/setIntHeader/addIntHeader 경로에서 헤더 누락 재현, 적용 후 정상 기록.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework\.security/spring-security-.*@5\.7\..*$</packageUrl>
<cve>CVE-2026-22732</cve>
</suppress>
</suppressions>
+92
View File
@@ -0,0 +1,92 @@
## OHS Static Resource 설정 예시
```apacheconf
<VirtualHost *:443>
ServerName weblogic-djb.rinjae.kr
<IfModule ossl_module>
SSLEngine on
SSLVerifyClient None
SSLProtocol TLSv1.2 TLSv1.3
SSLHonorCipherOrder on
SSLCipherSuite TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
SSLWallet "${ORACLE_INSTANCE}/config/fmwconfig/components/${COMPONENT_TYPE}/instances/${COMPONENT_NAME}/keystores/default"
<FilesMatch "\.(cgi|shtml|phtml|php)$">
SSLOptions +StdEnvVars
</FilesMatch>
<Directory "${ORACLE_INSTANCE}/config/fmwconfig/components/${COMPONENT_TYPE}/instances/${COMPONENT_NAME}/cgi-bin">
SSLOptions +StdEnvVars
</Directory>
BrowserMatch "MSIE [2-5]" \
nokeepalive ssl-unclean-shutdown \
downgrade-1.0 force-response-1.0
<IfModule mod_headers.c>
Header always set Strict-Transport-Security "max-age=63072000; preload; includeSubDomains"
</IfModule>
</IfModule>
Alias /css /apps/portal-static/css
Alias /js /apps/portal-static/js
Alias /img /apps/portal-static/img
Alias /images /apps/portal-static/images
Alias /webfonts /apps/portal-static/webfonts
Alias /font /apps/portal-static/font
Alias /html /apps/portal-static/html
Alias /plugins /apps/portal-static/plugins
Alias /favicon.png /apps/portal-static/favicon.png
<Directory "/apps/portal-static">
Require all granted
Options -Indexes
</Directory>
<LocationMatch "^/favicon\.png$|^/(css|js|img|images|webfonts|font|html|plugins)/">
Header set Cache-Control "no-cache"
</LocationMatch>
<IfModule weblogic_module>
<Location />
DirectoryIndex disabled
SetHandler weblogic-handler
WebLogicHost 172.30.1.100
WebLogicPort 39130
DynamicServerList OFF
ConnectTimeoutSecs 10
ConnectRetrySecs 2
WLProxySSL ON
</Location>
</IfModule>
<Location /css>
SetHandler None
</Location>
<Location /js>
SetHandler None
</Location>
<Location /img>
SetHandler None
</Location>
<Location /images>
SetHandler None
</Location>
<Location /webfonts>
SetHandler None
</Location>
<Location /font>
SetHandler None
</Location>
<Location /html>
SetHandler None
</Location>
<Location /plugins>
SetHandler None
</Location>
<Location /favicon.png>
SetHandler None
</Location>
</VirtualHost>
```
+343
View File
@@ -0,0 +1,343 @@
import argparse
from datetime import datetime
import http.cookiejar
from http.cookies import SimpleCookie
import json
import ssl
import sys
import time
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_HOST = 'https://api.jejubank.co.kr'
COOKIE_NAME = 'JSESSIONID_PORTAL'
USER_AGENT = (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/140.0.0.0 Safari/537.36'
)
COLUMNS = (
('', 3),
('대상', 4),
('결과', 10),
('쿠키', 8),
('CSRF', 8),
('HTTP', 4),
('요청 값', 10),
('요청 서버ID', 11),
('응답 값', 10),
('응답 서버ID', 11),
)
BORDER = '+' + '+'.join('-' * (width + 2) for _, width in COLUMNS) + '+'
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, request, fp, code, message, headers, new_url):
# 두 서버 비교에서는 지정한 주소의 첫 응답만 검사한다.
# 공유 세션 쿠키를 리다이렉트 대상에 전달하지 않는다.
return None
def integer_between(minimum, maximum):
def parse(value):
try:
number = int(value)
except ValueError:
raise argparse.ArgumentTypeError(
f'{minimum}~{maximum} 사이의 정수를 입력하세요.'
)
if not minimum <= number <= maximum:
raise argparse.ArgumentTypeError(
f'{minimum}~{maximum} 사이의 정수를 입력하세요.'
)
return number
return parse
def host_address(value):
value = value.strip()
message = 'http://호스트[:포트] 또는 https://호스트[:포트] 형식으로 입력하세요.'
try:
parsed = urllib.parse.urlsplit(value)
parsed.port # 포트 형식과 범위도 검사한다.
except ValueError:
raise argparse.ArgumentTypeError(message)
if (parsed.scheme not in ('http', 'https') or not parsed.hostname
or parsed.path not in ('', '/') or parsed.query or parsed.fragment
or parsed.username is not None or parsed.password is not None
or any(char.isspace() for char in value)):
raise argparse.ArgumentTypeError(message)
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, '', '', ''))
def parse_args(argv=None):
parser = argparse.ArgumentParser(
description='새 익명 세션으로 쿠키와 CSRF 토큰의 유지 여부를 표로 확인합니다.'
)
parser.add_argument(
'--host', type=host_address, default=DEFAULT_HOST,
metavar='주소', help=f'대상 호스트 주소 (기본값: {DEFAULT_HOST})',
)
parser.add_argument(
'--host2', type=host_address, metavar='주소',
help='두 번째 서버. A/B를 번갈아 요청 (리다이렉트 미추적)',
)
parser.add_argument(
'--cookie-mode', choices=('shared', 'separate'), default='shared',
help='shared: 직전 세션 쿠키 공유, separate: 서버 주소/포트별 저장소 분리 (기본값: shared)',
)
parser.add_argument(
'-n', '--count', type=integer_between(1, 100), default=10,
metavar='횟수', help='전체 요청 횟수: 1~100회 (기본값: 10회, 두 서버 모드도 합산)',
)
parser.add_argument(
'-d', '--delay', '--delay-ms', type=integer_between(0, 10000), default=50,
metavar='밀리초', help='요청 사이 대기시간: 0~10000ms (기본값: 50ms)',
)
tls_options = parser.add_mutually_exclusive_group()
tls_options.add_argument(
'--cacert', metavar='CA파일',
help='신뢰할 사설 CA 인증서 또는 인증서 묶음 파일 (PEM 형식)',
)
tls_options.add_argument(
'-k', '--insecure', action='store_true',
help='HTTPS 서버 인증서와 호스트명 검증 생략',
)
return parser.parse_args(argv)
def tls_context(args):
context = ssl.create_default_context()
if args.cacert:
context.load_verify_locations(cafile=args.cacert)
if args.insecure:
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
def cookie_from_headers(headers):
value = None
for header in headers:
parsed = SimpleCookie()
parsed.load(header)
if COOKIE_NAME in parsed:
value = parsed[COOKIE_NAME].value
return value
def cookie_for_url(jar, url):
request = urllib.request.Request(url)
jar.add_cookie_header(request)
return cookie_from_headers([request.get_header('Cookie', '')])
def host_key(url):
parsed = urllib.parse.urlsplit(url)
port = parsed.port if parsed.port is not None else (443 if parsed.scheme == 'https' else 80)
return parsed.scheme, parsed.hostname, port
def carry_session_cookie(request, value):
# 현재 호스트에 해당하는 다른 쿠키는 유지하고 포털 세션 쿠키만 이어 보낸다.
cookies = SimpleCookie()
cookies.load(request.get_header('Cookie', ''))
cookies[COOKIE_NAME] = value
request.add_unredirected_header(
'Cookie', cookies.output(header='', sep='; ').strip()
)
def server_id(cookie):
return cookie.split('!')[1] if cookie and '!' in cookie else '-'
def cell(value, width):
text = str(value)
# 한글은 보통 터미널에서 두 칸을 차지하므로 표시 폭을 기준으로 정렬한다.
display_width = sum(
0 if unicodedata.combining(char) else
2 if unicodedata.east_asian_width(char) in ('W', 'F') else 1
for char in text
)
return text + ' ' * max(0, width - display_width)
def print_row(values):
print('| ' + ' | '.join(
cell(value, width) for value, (_, width) in zip(values, COLUMNS)
) + ' |', flush=True)
def change_state(previous, current):
if previous is None:
return '최초'
return '유지' if previous == current else '!!변경!!'
def run_probe(args):
targets = [('A', args.host + '/api/session/csrf')]
if args.host2:
targets.append(('B', args.host2 + '/api/session/csrf'))
try:
context = tls_context(args)
except (OSError, ValueError) as error:
print(f'TLS 설정 실패: {error}', file=sys.stderr)
return 1
jar = http.cookiejar.CookieJar()
cookie_processor = urllib.request.HTTPCookieProcessor(jar)
handlers = [
urllib.request.HTTPSHandler(context=context),
cookie_processor,
]
if args.host2:
handlers.append(NoRedirectHandler())
client = urllib.request.build_opener(*handlers)
previous_cookie = None
previous_token = None
cookie_stores = {}
session_states = {}
totals = {'최초': 0, '유지': 0, '변경': 0}
completed = 0
failure = None
exit_code = 0
for target, url in targets:
print(f'대상 {target}: {url}')
if len(targets) == 2:
print('호출 순서: A -> B -> A -> B ... (클라이언트 1개, 전체 요청 횟수 기준)')
print('두 서버 비교에서는 3xx 리다이렉트를 따라가지 않습니다.')
if args.cookie_mode == 'separate':
print('쿠키 모드: separate | 서버 주소/포트별 저장소 및 변경 비교 기준 분리')
elif len(targets) == 2:
print(f'쿠키 모드: shared | 직전 {COOKIE_NAME}을 다음 서버로 전달')
if any(url.startswith('https://') for _, url in targets):
tls_mode = ('생략 (--insecure)' if args.insecure else
f'사설 CA 추가 ({args.cacert})' if args.cacert else '기본 CA 사용')
print(f'TLS 인증서 검증: {tls_mode}')
print(f'쿠키 키: {COOKIE_NAME} | 값: 앞 10자리 | 횟수: {args.count}회 | 딜레이: {args.delay}ms')
print(BORDER)
print_row([title for title, _ in COLUMNS])
print(BORDER, flush=True)
try:
for number in range(1, args.count + 1):
if number > 1 and args.delay:
time.sleep(args.delay / 1000)
target, url = targets[(number - 1) % len(targets)]
if args.cookie_mode == 'separate':
key = host_key(url)
if key not in cookie_stores:
cookie_stores[key] = http.cookiejar.CookieJar()
jar = cookie_stores[key]
# 순차 요청마다 같은 클라이언트의 쿠키 저장소만 교체한다.
cookie_processor.cookiejar = jar
previous_cookie, previous_token = session_states.get(key, (None, None))
request = urllib.request.Request(
url,
headers={
'Accept': 'application/json',
'Cache-Control': 'no-cache',
'User-Agent': USER_AGENT,
},
)
# 응답이 저장소를 갱신하기 전에 실제 전송할 쿠키를 기록한다.
jar.add_cookie_header(request)
if args.host2 and args.cookie_mode == 'shared' and previous_cookie is not None:
carry_session_cookie(request, previous_cookie)
request_cookie = cookie_from_headers([request.get_header('Cookie', '')])
response_cookie = None
status = '-'
try:
with client.open(request, timeout=10) as response:
status = response.status
response_url = response.geturl()
# 이번 응답의 Set-Cookie만 표시한다. 재발급이 없으면 '없음'.
response_cookie = cookie_from_headers(
response.headers.get_all('Set-Cookie') or []
)
data = json.load(response)
if args.host2 and args.cookie_mode == 'shared' and response_cookie is None:
# 다른 호스트에 직접 이어 보낸 쿠키는 저장소에 없을 수 있다.
# 재발급이 없으면 이번 요청에 실었던 세션을 계속 사용한다.
cookie = request_cookie
else:
cookie = cookie_for_url(jar, response_url)
token = data.get('token') if isinstance(data, dict) else None
if not cookie or not token:
raise ValueError('세션 쿠키 또는 CSRF 토큰 없음')
except urllib.error.HTTPError as error:
status = error.code
response_cookie = cookie_from_headers(
error.headers.get_all('Set-Cookie') or []
)
failure = f'{number}회: HTTP {status}'
error.close()
except Exception as error:
failure = f'{number}회: {error}'
if failure:
result, cookie_state, token_state = '!!실패!!', '-', '-'
exit_code = 1
else:
# 변경 판정은 앞 10자리가 아니라 전체 쿠키와 전체 토큰으로 비교한다.
cookie_state = change_state(previous_cookie, cookie)
token_state = change_state(previous_token, token)
if '!!변경!!' in (cookie_state, token_state):
outcome, result = '변경', '>>>변경<<<'
elif previous_cookie is None:
outcome, result = '최초', '[최초]'
else:
outcome, result = '유지', '[유지]'
totals[outcome] += 1
completed += 1
previous_cookie, previous_token = cookie, token
if args.cookie_mode == 'separate':
session_states[host_key(url)] = (cookie, token)
print_row((
number, target, result, cookie_state, token_state, status,
request_cookie[:10] if request_cookie else '없음',
server_id(request_cookie),
response_cookie[:10] if response_cookie else '없음',
server_id(response_cookie),
))
if failure:
break
except KeyboardInterrupt:
failure = '사용자가 중단했습니다.'
exit_code = 130
print(BORDER)
print(f'정상 조회: {completed}/{args.count}회 | 최초: {totals["최초"]}회 | 유지: {totals["유지"]}회 | 변경: {totals["변경"]}')
print('응답 값=없음: Set-Cookie 재발급 없음. 서버ID는 각 쿠키의 ! 뒤 식별값입니다.')
if failure:
print(f'실패/중단: {failure}')
return exit_code
def main(argv=None):
args = parse_args(argv)
started_counter = time.perf_counter()
started_at = datetime.now().astimezone()
print(f'시작 시각: {started_at.isoformat(sep=" ", timespec="milliseconds")}', flush=True)
try:
return run_probe(args)
except KeyboardInterrupt:
print('실패/중단: 사용자가 중단했습니다.')
return 130
finally:
finished_at = datetime.now().astimezone()
# 시스템 시각 보정에 영향받지 않도록 경과 시간은 별도 시계로 측정한다.
elapsed = time.perf_counter() - started_counter
print(f'종료 시각: {finished_at.isoformat(sep=" ", timespec="milliseconds")}')
print(f'총 소요 시간: {elapsed:.3f}초 (요청 간 딜레이 포함)', flush=True)
if __name__ == '__main__':
sys.exit(main())
+21
View File
@@ -0,0 +1,21 @@
// ./gradlew -I gradle/thymeleaf-verification.init.gradle :thymeleafCompatibilityDependencies
// Read-only dependency resolution; does not override versions or the normal build.
gradle.projectsEvaluated {
def portal = gradle.rootProject
portal.tasks.register('thymeleafCompatibilityDependencies') {
doLast {
def output = new File(portal.buildDir, 'reports/thymeleaf-compatibility')
output.mkdirs()
['runtimeClasspath', 'testRuntimeClasspath'].each { name ->
def artifacts = portal.configurations.getByName(name).resolvedConfiguration.resolvedArtifacts
def rows = artifacts.findAll {
it.id.componentIdentifier instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier
}.collect {
"${it.moduleVersion.id.group}:${it.name}\t${it.moduleVersion.id.version}\t${it.file.name}"
}.sort()
new File(output, "${name}.tsv").text = rows.join('\n') + '\n'
}
println "Dependency evidence: ${output}"
}
}
}
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Compare resolved dependency TSVs and inspect both WARs; exits nonzero on regression."""
import argparse
import hashlib
import io
import json
from pathlib import Path
import re
import zipfile
EXPECTED_CHANGES = {
"org.thymeleaf:thymeleaf": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
"org.thymeleaf:thymeleaf-spring5": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
"ognl:ognl": ("3.3.4", "3.3.5"),
}
EXPECTED_CLASSES = {
"org/thymeleaf/TemplateEngine.class": "thymeleaf-3.1.5.RELEASE.jar",
"org/thymeleaf/spring5/SpringTemplateEngine.class": "thymeleaf-spring5-3.1.5.RELEASE.jar",
"ognl/Ognl.class": "ognl-3.3.5.jar",
}
LOCAL_PREFIXES = (
"spring-boot-devtools", "spring-boot-starter-actuator", "spring-boot-actuator",
"micrometer-", "spring-boot-admin-", "tomcat-embed-websocket-",
)
def require(condition, message):
if not condition:
raise ValueError(message)
def dependencies(path):
result = {}
for line in path.read_text().splitlines():
module, version, filename = line.split("\t")
entries = result.setdefault(module, [])
require((version, filename) not in entries, "Duplicate artifact: " + filename)
require(not entries or entries[0][0] == version, "Multiple versions: " + module)
entries.append((version, filename))
for entries in result.values():
entries.sort()
return result
def compare(before, after):
old, new = dependencies(before), dependencies(after)
require(old.keys() == new.keys(), "Added/removed external modules: " + str(old.keys() ^ new.keys()))
changed = {key: (old[key][0][0], new[key][0][0]) for key in old if old[key] != new[key]}
require(changed == EXPECTED_CHANGES, "Unexpected dependency changes: " + str(changed))
return {"external_modules": len(new), "changes": changed}
def inspect_war(path):
owners = {name: [] for name in EXPECTED_CLASSES}
with zipfile.ZipFile(path) as war:
names = war.namelist()
require(len(names) == len(set(names)), "Duplicate ZIP entries: " + str(path))
jars = sorted(name for name in names if name.endswith(".jar"))
basenames = [Path(name).name for name in jars]
require(len(basenames) == len(set(basenames)), "Duplicate JAR names: " + str(path))
require(not any(Path(name).name.startswith("application-local") and name.endswith(".yml")
for name in names), "Local profile packaged: " + str(path))
for name in jars:
basename = Path(name).name
require(not basename.startswith(LOCAL_PREFIXES), "Excluded library packaged: " + name)
require(not re.match(r"spring-[\w-]+-6\.", basename), "Spring 6 packaged: " + name)
with zipfile.ZipFile(io.BytesIO(war.read(name))) as jar:
classes = set(jar.namelist())
require(not any(c.startswith("jakarta/servlet/") for c in classes),
"Jakarta Servlet classes packaged: " + name)
for target in owners:
if target in classes:
owners[target].append(name)
# Java 8 compatibility of each upgraded library's entry class.
require(int.from_bytes(jar.read(target)[6:8], "big") <= 52,
"Java >8 class: " + name + "!" + target)
for target, expected in EXPECTED_CLASSES.items():
require(owners[target] == ["WEB-INF/lib/" + expected],
"Wrong/duplicate class provider: " + target + " " + str(owners[target]))
for pattern, expected in [
(r"thymeleaf-\d", "thymeleaf-3.1.5.RELEASE.jar"),
(r"thymeleaf-spring\d-", "thymeleaf-spring5-3.1.5.RELEASE.jar"),
(r"ognl-", "ognl-3.3.5.jar"),
]:
require([n for n in basenames if re.match(pattern, n)] == [expected],
"Wrong/duplicate library version for " + expected)
return {"file": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"jar_count": len(jars), "class_providers": owners, "jars": jars}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--baseline", type=Path, required=True, help="Directory of baseline dependency TSVs")
parser.add_argument("--current", type=Path, required=True, help="Directory of current dependency TSVs")
parser.add_argument("--war", type=Path, action="append", required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
require(len(args.war) == 2 and len(set(args.war)) == 2, "Provide the standard WAR and bootWar")
result = {
"dependencies": {name: compare(args.baseline / (name + ".tsv"), args.current / (name + ".tsv"))
for name in ["runtimeClasspath", "testRuntimeClasspath"]},
"wars": [inspect_war(path) for path in args.war],
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
print("PASS: dependency changes limited to three modules; both WARs verified")
for war in result["wars"]:
print(war["file"], war["sha256"])
if __name__ == "__main__":
main()
+542
View File
@@ -9,9 +9,454 @@
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "1.63.0",
"esbuild": "^0.25.0",
"node-forge": "^1.3.1",
"sass": "^1.69.5"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
@@ -322,6 +767,22 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -349,6 +810,48 @@
"node": ">=8"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -389,6 +892,16 @@
"license": "MIT",
"optional": true
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"dev": true,
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
@@ -403,6 +916,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
+7 -2
View File
@@ -1,15 +1,20 @@
{
"name": "eapim-portal",
"version": "1.0.0",
"description": "SASS build system for EAPIM Portal",
"description": "SASS build system + forge custom bundle for EAPIM Portal",
"scripts": {
"test:moment": "playwright test --config=src/test/js/playwright.config.js",
"sass:build": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
"sass:build:minified": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.min.css --style=compressed",
"sass:watch": "sass --watch src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
"build": "npm run sass:build && npm run sass:build:minified",
"dev": "npm run sass:watch"
"dev": "npm run sass:watch",
"forge:build": "esbuild tools/forge-entry.js --bundle --minify --format=iife --global-name=forge --target=es5 --outfile=src/main/resources/static/js/lib/forge-crypto.min.js"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"esbuild": "^0.25.0",
"node-forge": "^1.3.1",
"sass": "^1.69.5"
},
"author": "",
@@ -5,19 +5,36 @@ import java.sql.SQLException;
import javax.sql.DataSource;
import org.jboss.logging.Logger;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.thymeleaf.spring5.SpringTemplateEngine;
import com.google.common.util.concurrent.Futures;
/**
* Readiness probe.
*
* <p>Distinct from {@link HealthCheckController} (liveness — servlet alive?).
* This endpoint validates both EMS and Gateway datasources via JDBC
* {@code Connection.isValid(timeout)} to confirm the app is ready to serve
* requests that depend on the database.
* {@code Connection.isValid(timeout)}, plus three libraries that WebLogic's
* oracle_common bundles at a different version from this app (see weblogic.xml
* {@code prefer-application-packages}): Thymeleaf's {@link SpringTemplateEngine}
* (jackson), jboss-logging, and Guava.
*
* <p>These exist because this controller bypasses view resolution entirely
* (plain {@code @RestController} JSON) — a classpath split between the app's
* copy and WebLogic's bundled copy of a preferred package can throw
* {@link LinkageError}/{@link java.util.ServiceConfigurationError} only on the
* real code paths that touch it, while this probe kept reporting 200. That
* already happened once for jackson (Thymeleaf's dialect/module discovery
* failed on every real page while /health/ready stayed green) — jboss-logging
* and Guava carry the same risk (confirmed version mismatch against
* oracle_common, not yet observed failing in production) so they get the same
* kind of forced-touch check here.
*
* <p>HTTP 200 + JSON when all checks pass.
* <p>HTTP 503 + JSON when any check fails — body still includes the per-component
@@ -30,24 +47,34 @@ public class ReadinessController {
private final DataSource portalDataSource;
private final DataSource gatewayDataSource;
private final SpringTemplateEngine templateEngine;
public ReadinessController(
@Qualifier("portalDataSource") DataSource portalDataSource,
@Qualifier("gatewayDataSource") DataSource gatewayDataSource) {
@Qualifier("gatewayDataSource") DataSource gatewayDataSource,
SpringTemplateEngine templateEngine) {
this.portalDataSource = portalDataSource;
this.gatewayDataSource = gatewayDataSource;
this.templateEngine = templateEngine;
}
@GetMapping(value = "/health/ready", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> ready() {
String ems = check(portalDataSource);
String gw = check(gatewayDataSource);
boolean ok = "UP".equals(ems) && "UP".equals(gw);
String ems = check(portalDataSource);
String gw = check(gatewayDataSource);
String view = checkTemplateEngine();
String logging = checkJbossLogging();
String guava = checkGuava();
boolean ok = "UP".equals(ems) && "UP".equals(gw) && "UP".equals(view)
&& "UP".equals(logging) && "UP".equals(guava);
String body = "{"
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
+ "\"ems\":\"" + ems + "\","
+ "\"gateway\":\""+ gw + "\""
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
+ "\"ems\":\"" + ems + "\","
+ "\"gateway\":\"" + gw + "\","
+ "\"view\":\"" + view + "\","
+ "\"logging\":\"" + logging + "\","
+ "\"guava\":\"" + guava + "\""
+ "}";
return ResponseEntity.status(ok ? 200 : 503)
@@ -55,6 +82,49 @@ public class ReadinessController {
.body(body);
}
/** {@code getConfiguration()} lazily runs Thymeleaf's dialect/module init on first call
* and caches it — so this is cheap once healthy, and reproduces the exact failure path
* a real page render would hit. Catches {@link Throwable}: init failures here have
* surfaced as {@link Error} (ServiceConfigurationError), not just Exception. */
private String checkTemplateEngine() {
try {
templateEngine.getConfiguration();
return "UP";
} catch (Throwable t) {
return down(t);
}
}
/** Forces jboss-logging's {@code Logger} + its bound provider (LogManager/JBoss/JUL
* discovery) to load and actually run a log-level check, not just resolve the class. */
private static String checkJbossLogging() {
try {
Logger log = Logger.getLogger(ReadinessController.class);
log.isDebugEnabled();
return "UP";
} catch (Throwable t) {
return down(t);
}
}
/** {@code Futures.immediateFuture} touches Guava's {@code util.concurrent} machinery
* (AbstractFuture/InternalFutureFailureAccess) — the part backed by the separate
* {@code failureaccess} jar, which is exactly where an app/WebLogic version mismatch
* would split across classloaders. */
private static String checkGuava() {
try {
Futures.immediateFuture(Boolean.TRUE).isDone();
return "UP";
} catch (Throwable t) {
return down(t);
}
}
private static String down(Throwable t) {
String msg = t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage();
return "DOWN:" + msg.replace('"', '\'').replace('\n', ' ').replace('\r', ' ');
}
private static String check(DataSource ds) {
if (ds == null) return "DOWN:NO_DATASOURCE";
try (Connection c = ds.getConnection()) {
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.apis.controller;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiListProperties;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
@@ -17,6 +18,7 @@ import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
@@ -37,6 +39,7 @@ public class ApiController {
private final ApiServiceService apiServiceService;
private final ApiSearchFacade apiSearchFacade;
private final ApiStatusCatalogService apiStatusCatalogService;
private final ApiListProperties apiListProperties;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_TOKEN_API_NAME = "인증";
@@ -88,7 +91,8 @@ public class ApiController {
@SuppressWarnings("unchecked")
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, pageable);
Pageable listPageable = PageRequest.of(pageable.getPageNumber(), apiListProperties.getPageSize(), pageable.getSort());
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, listPageable);
model.addAttribute("search", search);
model.addAttribute("services", searchResult.get("services"));
@@ -109,8 +113,8 @@ public class ApiController {
*/
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
int total = apis == null ? 0 : apis.size();
int fromIndex = Math.min(pageable.getPageNumber() * pageable.getPageSize(), total);
int toIndex = Math.min(fromIndex + pageable.getPageSize(), total);
int fromIndex = (int) Math.min(pageable.getOffset(), total);
int toIndex = (int) Math.min((long) fromIndex + pageable.getPageSize(), total);
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
return new PageImpl<>(content, pageable, total);
@@ -0,0 +1,35 @@
package com.eactive.apim.portal.apps.apis.service;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/** OPEN API 목록 및 앱/Webhook 신청·수정 API 선택 목록의 공통 페이지 크기. */
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiListProperties {
private static final String PAGE_SIZE_PROPERTY = "api.list.page-size";
private static final int DEFAULT_PAGE_SIZE = 15;
private final PortalPropertyService portalPropertyService;
/** PTL_PROPERTY(Portal / api.list.page-size)를 조회하고, 없으면 기본값으로 생성한다. */
public int getPageSize() {
String value = portalPropertyService.getOrCreateProperty(
"Portal", PAGE_SIZE_PROPERTY, String.valueOf(DEFAULT_PAGE_SIZE),
"API 목록 및 앱/Webhook 신청·수정 API 선택 목록의 페이지당 노출 개수 (양의 정수, 기본 15)");
try {
int size = Integer.parseInt(value == null ? "" : value.trim());
if (size > 0) {
return size;
}
} catch (NumberFormatException ignored) {
// 비어 있거나 정수가 아닌 설정은 기본값을 사용한다.
}
log.warn("{} 값이 올바르지 않음('{}') - 기본값 {} 사용", PAGE_SIZE_PROPERTY, value, DEFAULT_PAGE_SIZE);
return DEFAULT_PAGE_SIZE;
}
}
@@ -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<String, Object> mergedComponents, ObjectMapper objectMapper) {
@@ -1,18 +1,28 @@
package com.eactive.apim.portal.apps.app.service;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
/**
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
*
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code admin.base-url}) 에서 조회한다.</p>
*
* <p><b>다중 base URL / failover</b> — 프로퍼티 값에 콤마({@code ,}) 또는 개행으로 여러 admin 주소를 넣을 수 있다.
* 호출은 <b>기재된 순서대로</b> 시도하며, 통신 오류(연결 실패/타임아웃/HTTP 오류)면 다음 주소로 넘어간다.
* 모두 실패하면 마지막 오류를 던진다. 반면 admin 이 정상 응답하면서 {@code success=false} 를 준 것은
* 업무 처리 실패이므로 <b>failover 하지 않고</b> 즉시 실패시킨다(다른 노드도 같은 결과이며 중복 처리 위험).</p>
*/
@Slf4j
@Service
@@ -22,6 +32,8 @@ public class AdminGatewayClient {
private static final String PROP_GROUP = "Portal";
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
private static final String PROP_ADMIN_BASE_URL_DESC =
"admin(관리자포털) 내부 API base URL. 콤마(,) 또는 줄바꿈으로 여러 개 지정 시 앞에서부터 failover";
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
private final RestTemplate restTemplate;
@@ -30,26 +42,79 @@ public class AdminGatewayClient {
/**
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
*
* <p>base URL 이 여러 개면 앞에서부터 순서대로 시도한다(failover).</p>
*
* @param clientId 차단할 클라이언트 ID
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
* @throws RuntimeException admin 미응답/네트워크 오류(전 노드 실패) 또는 admin 처리 실패 시 (호출측에서 처리)
*/
public void blockClient(String clientId) {
String baseUrl = portalPropertyService.getOrCreateProperty(
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
List<String> baseUrls = resolveBaseUrls();
String url = stripTrailingSlashes(baseUrl) + CLIENT_BLOCK_PATH;
RestClientException lastError = null;
for (int i = 0; i < baseUrls.size(); i++) {
String baseUrl = baseUrls.get(i);
String url = baseUrl + CLIENT_BLOCK_PATH;
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
ResponseEntity<Map> response;
try {
response = restTemplate.postForEntity(url, null, Map.class, clientId);
} catch (RestClientException e) {
// 통신 계층 실패 — 다음 admin 주소로 failover
lastError = e;
log.warn("admin clientBlock 호출 실패({}/{}) - baseUrl={}, clientId={}, cause={}",
i + 1, baseUrls.size(), baseUrl, clientId, e.toString());
continue;
}
Map<?, ?> body = response.getBody();
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
if (!success) {
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
// 여기까지 왔으면 admin 이 응답한 것 — 업무 실패는 failover 대상이 아니다.
Map<?, ?> body = response.getBody();
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
if (!success) {
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
throw new IllegalStateException(
"admin clientBlock 처리 실패 - baseUrl=" + baseUrl + ", clientId=" + clientId + ", msg=" + msg);
}
log.info("admin GW 차단/리로드 위임 성공 - baseUrl={}, clientId={}", baseUrl, clientId);
return;
}
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
throw new IllegalStateException(
"admin clientBlock 호출 실패 - 모든 admin 주소 응답 없음 (" + baseUrls + "), clientId=" + clientId, lastError);
}
/**
* 프로퍼티의 admin base URL 목록을 순서대로 반환한다.
*
* <p>구분자는 콤마({@code ,})와 개행({@code \r\n})이며, 공백 항목과 중복은 제거하고 순서는 보존한다.
* 값이 비어 있거나 유효 항목이 없으면 기본값 하나만 반환한다.</p>
*/
private List<String> resolveBaseUrls() {
String raw = portalPropertyService.getOrCreateProperty(
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, PROP_ADMIN_BASE_URL_DESC);
List<String> urls = parseBaseUrls(raw);
if (urls.isEmpty()) {
log.warn("admin.base-url 프로퍼티가 비어 있어 기본값 사용 - {}", DEFAULT_ADMIN_BASE_URL);
return parseBaseUrls(DEFAULT_ADMIN_BASE_URL);
}
return urls;
}
/**
* 콤마/개행으로 구분된 base URL 문자열을 정규화된 목록으로 파싱한다.
*/
static List<String> parseBaseUrls(String raw) {
Set<String> ordered = new LinkedHashSet<>();
if (raw != null) {
for (String token : raw.split("[,\\r\\n]")) {
String url = stripTrailingSlashes(token.trim());
if (!url.isEmpty()) {
ordered.add(url);
}
}
}
return new ArrayList<>(ordered);
}
/**
@@ -52,9 +52,10 @@ public class ApiStatisticsController {
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
LocalDate today = LocalDate.now();
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
searchDto.setStartDate(LocalDate.now().minusDays(7));
searchDto.setEndDate(LocalDate.now());
searchDto.setStartDate(today.minusDays(7));
searchDto.setEndDate(today);
searchDto.setClientId(null);
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
@@ -62,6 +63,9 @@ public class ApiStatisticsController {
model.addAttribute("details", result.getDetails());
model.addAttribute("periods", result.getPeriods());
model.addAttribute("searchDto", searchDto);
// 브라우저 시간대/시계와 관계없이 서버와 동일한 조회 경계를 적용한다.
model.addAttribute("statsMinDate", today.minusYears(1).toString());
model.addAttribute("statsMaxDate", today.toString());
// 월별 선택 가능 월 + 집계 안내 문구 데이터
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
@@ -124,7 +128,8 @@ public class ApiStatisticsController {
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
return searchDto.isMonthly()
? "조회할 월이 올바르지 않습니다."
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
: "조회 기간은 1년 전부터 오늘까지의 날짜 중 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS
+ "일(시작일·종료일 포함)로 선택해주세요.";
}
private PortalOrg getPortalOrg() {
@@ -15,7 +15,7 @@ import org.springframework.format.annotation.DateTimeFormat;
public class ApiStatisticsSearchDto {
/**
* 최대 조회 가능 일수 (일별 모드).
* 한 번에 조회 가능 일수 (일별 모드, 시작일·종료일 포함).
*/
public static final int MAX_DATE_RANGE_DAYS = 40;
@@ -59,17 +59,22 @@ public class ApiStatisticsSearchDto {
}
/**
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
* 1년 전부터 오늘까지의 날짜 중 최대 40일인지 검증 (양 끝 포함).
*/
public boolean isValidDateRange() {
return isValidDateRange(LocalDate.now());
}
boolean isValidDateRange(LocalDate today) {
if (startDate == null || endDate == null) {
return false;
}
if (startDate.isAfter(endDate)) {
return false;
}
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate);
return daysBetween <= MAX_DATE_RANGE_DAYS;
LocalDate earliestDate = today.minusYears(1);
return !startDate.isBefore(earliestDate) && !endDate.isAfter(today)
&& ChronoUnit.DAYS.between(startDate, endDate) < MAX_DATE_RANGE_DAYS;
}
/**
@@ -10,7 +10,6 @@ import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.config.PortalProperties;
@@ -22,21 +21,15 @@ import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -64,7 +57,6 @@ public class UserRegisterController {
private final AgreementsFacade agreementsFacade;
private final PortalProperties portalProperties;
private final UserInvitationRepository userInvitationRepository;
private final EncryptionUtil encryptionUtil;
private final AgreementValidator agreementValidator;
private final PortalUserAuthService portalUserAuthService;
@@ -11,8 +11,6 @@ import com.eactive.apim.portal.apps.user.service.PortalOrgService;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.common.exception.SystemException;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.file.service.FileTypeContext;
@@ -20,26 +18,14 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
@@ -58,8 +44,6 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
private final PasswordEncoder passwordEncoder;
private final AgreementValidator agreementValidator;
private final ApprovalService approvalService;
private final MessageHandlerService messageHandlerService;
private final EncryptionUtil encryptionUtil;
@Override
@Transactional
@@ -213,29 +197,10 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
approvalService.createUserApproval(newUser);
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
// sendActivationEmail(newUser);
return new ValidationResponse(true, "법인 사용자 등록 신청이 완료되었습니다.");
}
private void sendActivationEmail(PortalUser newUser) {
MessageRecipient recipient = new MessageRecipient();
recipient.setUsername(newUser.getUserName());
recipient.setUserId(newUser.getEmailAddr());
recipient.setPhone(newUser.getMobileNumber());
HashMap<String, Object> params = new HashMap<>();
String tokenValue = newUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + newUser.getId();
try {
String encToken = encryptionUtil.encrypt(tokenValue);
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
throw new SystemException("암호화 모듈 오류");
}
}
// 기존 사용자를 법인 사용자로 전환하는 메서드
private ValidationResponse convertExistingUserToCorporate(
PortalUser existingUser,
@@ -2,7 +2,6 @@ package com.eactive.apim.portal.apps.user.facade;
import com.eactive.apim.portal.agreements.entity.AgreementType;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.auth.service.AuthNumberGenerator;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
@@ -11,15 +10,11 @@ import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.apps.user.validator.PasswordValidator;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -31,7 +26,6 @@ import org.springframework.validation.BindingResult;
import javax.servlet.http.HttpSession;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Optional;
@Service
@@ -49,9 +43,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
private final PasswordValidator passwordValidator;
private final PasswordEncoder passwordEncoder;
private final AgreementValidator agreementValidator;
private final MessageHandlerService messageHandlerService;
private final EncryptionUtil encryptionUtil;
private final AuthNumberGenerator authNumberGenerator;
@Override
@@ -159,8 +150,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
}
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
// sendEmailActivation(newUser);
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
}
@@ -229,28 +218,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return false;
}
private void sendEmailActivation(PortalUser registeredUser) {
MessageRecipient recipient = new MessageRecipient();
recipient.setUsername(registeredUser.getUserName());
recipient.setUserId(registeredUser.getEmailAddr());
recipient.setPhone(registeredUser.getMobileNumber());
// 25.10.01 - 이메일 링크 방식으로 접근 불가이기에 SMS 인증방식과 동일하게 대체
HashMap<String, Object> params = new HashMap<>();
String tokenValue = String.valueOf(authNumberGenerator.generateAuthNumber());
// String tokenValue = registeredUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + registeredUser.getId();
params.put("token", tokenValue);
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
// try {
// String encToken = encryptionUtil.encrypt(tokenValue);
// params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
// messageHandlerService.publishEvent(UserEmailActivationEvent.KEY, recipient, params);
// } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
// throw new SystemException("암호화 모듈 오류");
// }
}
@Override
@Transactional
public ValidationResponse processInvitation(String action, UserInvitation invitation) {
@@ -16,23 +16,14 @@ import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.portaluser.event.UserPasswordResetEvent;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
@@ -54,7 +45,6 @@ public class PortalUserAuthService implements UserDetailsService {
private final PasswordEncoder passwordEncoder;
private final MessageHandlerService messageHandlerService;
private final MessageRequestRepository messageRequestRepository;
private final EncryptionUtil encryptionUtil;
private final LoginFinalizer loginFinalizer;
private final PasswordService passwordService;
@@ -16,6 +16,7 @@ import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
import com.eactive.apim.portal.djb.footer.RelatedSite;
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
import com.eactive.apim.portal.djb.guide.GuideProperty;
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordCryptoProperties;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
@ControllerAdvice
@@ -51,6 +52,9 @@ public class GlobalControllerAdvice {
@Autowired
private Environment environment;
@Autowired
private PasswordCryptoProperties passwordCryptoProperties;
@ModelAttribute("breadcrumb")
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
String currentPath = request.getRequestURI();
@@ -125,6 +129,25 @@ public class GlobalControllerAdvice {
return passwordPolicyProperties.isKeyboardSequenceBlocked();
}
/**
* 비밀번호 전송암호화 설정(yml {@code portal.security.password-encrypt.*}).
* head 의 {@code window.__PASSWORD_CRYPTO__} 로 내려가 password-crypto.js 가 읽는다.
*
* <ul>
* <li>{@code enabled} - 꺼져 있으면 화면은 아무것도 하지 않고 평문 전송한다.</li>
* <li>{@code policy} - 평문 비밀번호 처리 정책. 화면은 경고 팝업 노출 여부·문구를 여기서 정한다.</li>
* <li>{@code softwareFallback} - {@code crypto.subtle} 이 없을 때 forge 번들을 내려받아 쓸지.</li>
* </ul>
*/
@ModelAttribute("passwordCrypto")
public Map<String, Object> passwordCrypto() {
Map<String, Object> config = new java.util.LinkedHashMap<>();
config.put("enabled", passwordCryptoProperties.isEnabled());
config.put("policy", passwordCryptoProperties.getPlaintextPolicy().name());
config.put("softwareFallback", passwordCryptoProperties.isSoftwareFallback());
return config;
}
/**
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
*/
@@ -1,8 +1,7 @@
package com.eactive.apim.portal.common.exception;
import java.util.Arrays;
import java.util.stream.Collectors;
import java.util.regex.Pattern;
import javax.servlet.http.HttpServletRequest;
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
@@ -36,23 +35,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 +64,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 +125,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 +174,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;
}
@@ -159,10 +193,9 @@ public class PortalGlobalExceptionHandler {
*/
@ExceptionHandler(value = Exception.class)
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
String requestParams = request.getParameterMap().entrySet()
.stream()
.map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue()))
.collect(Collectors.joining(", "));
// 비밀번호·시크릿·토큰 계열 파라미터는 값을 가린다. 전송암호화가 켜져 있어도
// 이 시점의 파라미터는 이미 복호화된 평문이다.
String requestParams = StringMaskingUtil.maskParameterMap(request.getParameterMap());
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
@@ -184,7 +217,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 +228,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;
}
}
@@ -0,0 +1,90 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
/**
* 멀티파트(파일 업로드 동반) 요청 전용 복호화 래퍼.
*
* <p><b>배경</b> — {@link DecryptingRequestWrapper} 는 {@code getParameter()} 호출 시
* {@code super.getParameter()} 위임 결과를 복호화하는 방식이다. 그런데 Spring 의
* {@link org.springframework.web.multipart.support.MultipartFilter} 가 만드는
* {@code MultipartHttpServletRequest} 는 자신이 직접 파싱한 폼 필드(파일이 아닌 text part) 값을
* {@code getParameter()} 에서 곧바로 반환하고 {@code super.getParameter()} 로 위임하지 않는다.
* 그 결과 {@code DecryptingRequestWrapper} 가 멀티파트 파싱 "이전"(안쪽)에 씌워지면, 멀티파트 안의
* 비밀번호 파라미터는 복호화되지 않은 봉투(ENC1) 문자열 그대로 컨트롤러까지 전달된다 — 법인가입처럼
* 파일 첨부(사업자등록증)와 비밀번호가 같은 폼에 있는 경우 재현된다.
*
* <p><b>해결</b> — {@link PasswordDecryptFilter} 를 MultipartFilter "이후"에 실행되도록 순서를
* 옮기면(그리고 Lucy XSS 필터보다는 여전히 앞서도록), 이 필터가 감싸는 대상이 이미
* {@code MultipartHttpServletRequest} 다. 이때는 이 클래스로 감싸 {@link MultipartHttpServletRequest}
* 인터페이스 자체를 구현하고 파일 관련 메서드는 원본 멀티파트 요청에 위임한다 — Spring 의
* {@code MultipartFile} 바인딩({@code WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class)})이
* 이 래퍼를 그대로 인식하도록 하기 위함이다. 파라미터(텍스트 필드) 복호화 로직은 부모
* {@link DecryptingRequestWrapper} 그대로 재사용한다.
*/
public class DecryptingMultipartRequestWrapper extends DecryptingRequestWrapper
implements MultipartHttpServletRequest {
private final MultipartHttpServletRequest multipartRequest;
public DecryptingMultipartRequestWrapper(MultipartHttpServletRequest request,
PasswordEnvelopeCodec codec,
PasswordKeyStore keyStore,
PasswordCryptoProperties properties) {
super(request, codec, keyStore, properties);
this.multipartRequest = request;
}
@Override
public Iterator<String> getFileNames() {
return multipartRequest.getFileNames();
}
@Override
public MultipartFile getFile(String name) {
return multipartRequest.getFile(name);
}
@Override
public List<MultipartFile> getFiles(String name) {
return multipartRequest.getFiles(name);
}
@Override
public Map<String, MultipartFile> getFileMap() {
return multipartRequest.getFileMap();
}
@Override
public MultiValueMap<String, MultipartFile> getMultiFileMap() {
return multipartRequest.getMultiFileMap();
}
@Override
public String getMultipartContentType(String paramOrFileName) {
return multipartRequest.getMultipartContentType(paramOrFileName);
}
@Override
public HttpMethod getRequestMethod() {
return multipartRequest.getRequestMethod();
}
@Override
public HttpHeaders getRequestHeaders() {
return multipartRequest.getRequestHeaders();
}
@Override
public HttpHeaders getMultipartHeaders(String paramOrFileName) {
return multipartRequest.getMultipartHeaders(paramOrFileName);
}
}
@@ -0,0 +1,181 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.security.PrivateKey;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
/**
* 봉투(ENC1) 형식으로 들어온 파라미터 값을 평문으로 되돌리는 요청 래퍼.
*
* <p><b>지연 복호화가 필수다.</b> 생성자에서 {@code getParameterMap()} 을 부르면 컨테이너가 요청 본문을
* 파싱해버려, 본문을 직접 읽는 필터({@code ApiTesterFilter})가 이후 {@code getInputStream()} 을 못 쓴다.
* 따라서 값은 {@code getParameter*} 호출 시점에만 건드린다.</p>
*
* <p>Lucy XSS 필터({@code order = MIN_VALUE + 3})보다 <b>앞</b>({@code MIN_VALUE + 2})에서 이 래퍼가
* 씌워지므로, 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거친다. 순서가 뒤바뀌면 특수문자가 든
* 비밀번호의 이스케이프 여부가 달라져 기존 계정 로그인이 깨진다.</p>
*
* <p><b>멀티파트(파일 업로드) 요청 주의</b> — {@code getParameter()} 는 {@code super.getParameter()} 위임
* 결과를 복호화하는 방식이라, 이 래퍼가 감싸는 시점에 {@code super} 가 이미 멀티파트 파싱이 끝난
* {@code MultipartHttpServletRequest} 여야만 폼 필드(파일이 아닌 text part)를 볼 수 있다 — Spring 의
* 멀티파트 파라미터 맵은 자신이 파싱한 값을 곧바로 반환하고 감싸고 있는 원본 요청으로 위임하지 않기
* 때문이다. 그래서 {@link PasswordDecryptFilter} 는 MultipartFilter({@code order = MIN_VALUE + 1})
* "이후"에 실행되도록 등록돼 있고, 감싸는 대상이 {@code MultipartHttpServletRequest} 면 이 클래스 대신
* {@link DecryptingMultipartRequestWrapper}(이 클래스를 상속하며 멀티파트 인터페이스도 구현)를 쓴다.</p>
*/
@Slf4j
public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
private final PasswordEnvelopeCodec codec;
private final PasswordKeyStore keyStore;
private final PasswordCryptoProperties properties;
/** 봉투 원문 → 평문. 같은 값이 여러 번 조회돼도 RSA 연산은 한 번만 한다. */
private final Map<String, String> decrypted = new HashMap<>();
/** 요청 처리 후 폐기할 keyId(1회용 키 모드). */
private final Set<String> usedKeyIds = new LinkedHashSet<>();
private Map<String, String[]> parameterMapCache;
/**
* 평문 비밀번호 경고를 요청당 한 번만 남기기 위한 표시.
* 파라미터는 컨트롤러·검증기에서 여러 번 조회되고, 같은 폼에 비밀번호 계열 필드가 둘 이상인
* 경우도 흔해서(비밀번호/비밀번호확인) 그대로 두면 한 번의 제출이 로그 여러 줄을 만든다.
*/
private boolean plaintextWarned;
public DecryptingRequestWrapper(HttpServletRequest request,
PasswordEnvelopeCodec codec,
PasswordKeyStore keyStore,
PasswordCryptoProperties properties) {
super(request);
this.codec = codec;
this.keyStore = keyStore;
this.properties = properties;
}
@Override
public String getParameter(String name) {
return convert(name, super.getParameter(name));
}
@Override
public String[] getParameterValues(String name) {
String[] values = super.getParameterValues(name);
if (values == null) {
return null;
}
String[] converted = new String[values.length];
for (int i = 0; i < values.length; i++) {
converted[i] = convert(name, values[i]);
}
return converted;
}
@Override
public Map<String, String[]> getParameterMap() {
if (parameterMapCache == null) {
Map<String, String[]> source = super.getParameterMap();
Map<String, String[]> result = new HashMap<>(Math.max(16, source.size() * 2));
for (Map.Entry<String, String[]> entry : source.entrySet()) {
String name = entry.getKey();
String[] values = entry.getValue();
String[] converted = new String[values.length];
for (int i = 0; i < values.length; i++) {
converted[i] = convert(name, values[i]);
}
result.put(name, converted);
}
parameterMapCache = Collections.unmodifiableMap(result);
}
return parameterMapCache;
}
/** 요청 처리가 끝난 뒤 필터가 호출한다. 1회용 키를 폐기해 재전송을 막는다. */
void consumeUsedKeys() {
for (String keyId : usedKeyIds) {
keyStore.consume(keyId, (HttpServletRequest) getRequest());
}
usedKeyIds.clear();
}
private String convert(String name, String value) {
if (value == null || value.isEmpty()) {
return value;
}
if (!codec.isEnvelope(value)) {
return handlePlaintext(name, value);
}
String cached = decrypted.get(value);
if (cached != null) {
return cached;
}
String keyId = codec.keyIdOf(value);
if (keyId == null) {
log.warn("봉투 형식 오류 - uri={}, param={}", getRequestURI(), name);
return failed(value);
}
PrivateKey privateKey = keyStore.resolve(keyId, (HttpServletRequest) getRequest());
if (privateKey == null) {
log.warn("전송암호화 키를 찾을 수 없음(만료·인스턴스 불일치) - uri={}, param={}, keyId={}",
getRequestURI(), name, keyId);
return failed(value);
}
try {
String plain = codec.decrypt(value, privateKey);
decrypted.put(value, plain);
usedKeyIds.add(keyId);
return plain;
} catch (PasswordDecryptException e) {
log.warn("전송암호화 복호화 실패 - uri={}, param={}, keyId={}, reason={}",
getRequestURI(), name, keyId, e.getMessage());
usedKeyIds.add(keyId);
return failed(value);
}
}
/**
* 봉투가 씌워지지 않은 파라미터 처리. 비밀번호 계열이 아니면 그대로 통과시킨다.
*
* <p>{@code ENFORCE} 에서 예외를 던지면 로그인 경로가 500 이 되므로 빈 값으로 바꿔
* 기존 인증 실패 흐름(아이디/비밀번호 확인)을 타게 한다.</p>
*/
private String handlePlaintext(String name, String value) {
PasswordCryptoProperties.PlaintextPolicy policy = properties.getPlaintextPolicy();
if (policy == PasswordCryptoProperties.PlaintextPolicy.NONE
|| !PasswordParamNames.isPasswordLike(name)) {
return value;
}
boolean reject = policy == PasswordCryptoProperties.PlaintextPolicy.ENFORCE;
if (!plaintextWarned) {
plaintextWarned = true;
log.warn("암호화되지 않은 비밀번호 파라미터 {} - policy={}, transport={}, uri={}, param={}",
reject ? "거부" : "허용", policy,
RequestTransport.isSecure((HttpServletRequest) getRequest()) ? "https" : "http",
getRequestURI(), name);
}
return reject ? "" : value;
}
/**
* 복호화 실패 시의 값. 봉투 문자열을 그대로 흘려보내면 비밀번호 정책 검증 등이 엉뚱하게 통과할 수 있어
* 빈 값으로 바꾼다. 결과적으로 사용자에게는 일반적인 입력값 오류로 보인다.
*/
private String failed(String rawValue) {
decrypted.put(rawValue, "");
return "";
}
}
@@ -0,0 +1,21 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 클라이언트에 내려줄 공개키 1건. {@code PasswordKeyController} 응답 본문이기도 하다.
*/
@Data
@AllArgsConstructor
public class IssuedKey {
/** 봉투(ENC1)의 두 번째 세그먼트로 되돌아오는 키 식별자. */
private final String keyId;
/** X.509 SubjectPublicKeyInfo(SPKI) DER 을 표준 base64 로 인코딩한 값. */
private final String publicKey;
/** 남은 수명(초). 클라이언트 캐시 판단용. */
private final int expiresIn;
}
@@ -0,0 +1,76 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 비밀번호 전송암호화 설정. {@code portal.security.password-encrypt.*}
*
* <p>브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막기 위한 기능이다.</p>
*
* <p><b>이 기능은 XSS 방어도, MITM 방어도 아니다.</b> 스크립트가 주입되면 입력창에서 직접 탈취할 수 있고,
* HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다. "전송 페이로드 평문 노출" 점검 지적에
* 대한 대응 범위로만 이해할 것.</p>
*/
@Data
@Component
@ConfigurationProperties(prefix = "portal.security.password-encrypt")
public class PasswordCryptoProperties {
/**
* RSA 개인키 보관 범위.
*
* <ul>
* <li>{@code REQUEST} - 폼 진입마다 1회용 키를 발급하고 요청 1회 사용 후 폐기. 재전송 공격 차단이 가장 강하다.</li>
* <li>{@code SESSION} - 세션 단위로 키를 보관. 세션 복제 환경에 유리하다.</li>
* <li>{@code SERVER} - 서버 고정 키쌍 + TTL 로테이션. 무상태라 가장 단순하지만 재전송 방지 수단이 없다.</li>
* </ul>
*/
public enum KeyScope {
REQUEST, SESSION, SERVER
}
/**
* 봉투(ENC1)가 적용되지 않은 <b>평문 비밀번호 파라미터</b>를 서버가 어떻게 다룰지.
* 전송 구간(HTTP/HTTPS) 자체를 막는 스위치가 아니다 — 전송 구간은 경고 문구에만 영향을 준다.
*
* <ul>
* <li>{@code NONE} - 무동작. 평문을 그대로 받는다.</li>
* <li>{@code PERMISSIVE} - 평문을 받되 서버 로그에 경고를 남기고, 화면에도 경고 팝업을 띄운다.</li>
* <li>{@code ENFORCE} - 평문 비밀번호를 거부한다(빈 값 치환 → 인증 실패).</li>
* </ul>
*
* <p>{@code ENFORCE} 는 전송 구간이 HTTP 여도 안전하게 켤 수 있다. {@link #softwareFallback} 이 켜져 있으면
* {@code crypto.subtle} 을 못 쓰는 환경에서도 클라이언트가 봉투를 만들기 때문이다. 다만 JS 를 끈
* 브라우저는 로그인하지 못하므로 {@code PERMISSIVE} 로 운영해 경고 로그를 지켜본 뒤 승격한다.</p>
*/
public enum PlaintextPolicy {
NONE, PERMISSIVE, ENFORCE
}
/** 마스터 스위치. 꺼져 있으면 필터·엔드포인트가 모두 무동작이고 화면은 평문 전송한다. */
private boolean enabled = false;
/** 키 보관 범위. */
private KeyScope keyScope = KeyScope.REQUEST;
/** 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기. */
private int keyTtlSeconds = 300;
/** 평문 비밀번호 파라미터 처리 정책. */
private PlaintextPolicy plaintextPolicy = PlaintextPolicy.NONE;
/**
* {@code crypto.subtle} 을 쓸 수 없는 환경(원격 오리진 HTTP = 비 secure context)에서
* 순수 JS 구현(forge)으로 봉투를 만들지 여부.
*
* <p>forge 번들은 {@code crypto.subtle} 이 없을 때만 동적으로 내려가므로 HTTPS 운영 환경에서는
* 전송 바이트가 0 이다. 즉 이 값은 사실상 킬스위치다. 끄면 HTTP 개발환경은 평문으로 폴백한다
* ({@link #plaintextPolicy} 가 {@code ENFORCE} 면 로그인 불가).</p>
*/
private boolean softwareFallback = true;
/** RSA 키 길이. */
private int rsaKeySize = 2048;
}
@@ -0,0 +1,18 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
/**
* 봉투 복호화 실패. 화면으로 스택을 올리지 않고 필터에서 흡수한다.
* 메시지에 평문이나 키 자료를 담지 않는다.
*/
public class PasswordDecryptException extends RuntimeException {
private static final long serialVersionUID = 1L;
public PasswordDecryptException(String message) {
super(message);
}
public PasswordDecryptException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,81 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* 봉투(ENC1) 파라미터를 평문으로 되돌리는 서블릿 필터.
*
* <p>{@code PortalConfigSecurity} 에서 order {@code Integer.MIN_VALUE + 2} 로 등록해
* MultipartFilter({@code MIN_VALUE + 1}) "이후", Lucy XSS 필터({@code MIN_VALUE + 3}) "이전"에
* 실행된다. 자세한 이유는 {@link DecryptingRequestWrapper} / {@link DecryptingMultipartRequestWrapper}
* 주석 참고 — 멀티파트(파일 업로드 동반) 요청은 MultipartFilter 가 만든
* {@code MultipartHttpServletRequest} 가 폼 필드를 자체 파싱해 반환하므로, 이 필터가 그보다
* 먼저 실행되면 비밀번호 파라미터가 복호화되지 않은 채로 컨트롤러까지 전달된다(법인가입처럼
* 파일 첨부와 비밀번호가 같은 폼에 있는 경우 재현).</p>
*
* <p>기능이 꺼져 있거나 POST 가 아니면 아무것도 하지 않는다. 래퍼는 파라미터를 조회할 때만
* 복호화하므로, 봉투가 없는 요청에는 사실상 비용이 없다.</p>
*
* <p><b>{@code /internal/**} 은 대상에서 제외한다.</b> playwright 전용 테스트 정리 API
* ({@code TestCleanupInternalController} 등)는 공유 토큰({@code X-Internal-Token}) + IP 허용목록으로
* 이미 인증되는 서버-to-서버 호출이라 브라우저 암호화 대상이 아니다({@code MenuAccessInterceptor} 가
* 같은 이유로 {@code /internal/**} 을 제외하는 것과 동일 패턴). 이 경로를 필터에 그대로 태우면, 도구가
* 보내는 평문 {@code password} 파라미터가 {@code plaintext-policy=ENFORCE} 설정에 걸려 빈 문자열로
* 치환되고 내부 API 는 "필수값 누락"으로 거부한다(예: {@code /internal/test-cleanup/password}).</p>
*/
@Component
public class PasswordDecryptFilter implements Filter {
private static final String INTERNAL_API_PREFIX = "/internal/";
private final PasswordCryptoProperties properties;
private final PasswordEnvelopeCodec codec;
private final PasswordKeyStore keyStore;
public PasswordDecryptFilter(PasswordCryptoProperties properties,
PasswordEnvelopeCodec codec,
PasswordKeyStore keyStore) {
this.properties = properties;
this.codec = codec;
this.keyStore = keyStore;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!properties.isEnabled() || !(request instanceof HttpServletRequest)) {
chain.doFilter(request, response);
return;
}
HttpServletRequest httpRequest = (HttpServletRequest) request;
if (!"POST".equalsIgnoreCase(httpRequest.getMethod()) || isInternalApiRequest(httpRequest)) {
chain.doFilter(request, response);
return;
}
DecryptingRequestWrapper wrapper = httpRequest instanceof MultipartHttpServletRequest
? new DecryptingMultipartRequestWrapper((MultipartHttpServletRequest) httpRequest, codec, keyStore, properties)
: new DecryptingRequestWrapper(httpRequest, codec, keyStore, properties);
try {
chain.doFilter(wrapper, response);
} finally {
wrapper.consumeUsedKeys();
}
}
private static boolean isInternalApiRequest(HttpServletRequest request) {
String path = request.getRequestURI().substring(request.getContextPath().length());
return path.startsWith(INTERNAL_API_PREFIX);
}
}
@@ -0,0 +1,104 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.PrivateKey;
import java.security.spec.MGF1ParameterSpec;
import java.util.Base64;
/**
* 클라이언트가 만든 봉투(envelope) 문자열의 파싱·복호화.
*
* <pre>
* ENC1.&lt;keyId&gt;.&lt;b64url(RSA-OAEP(AES키))&gt;.&lt;b64url(iv 12B)&gt;.&lt;b64url(AES-GCM 암호문+태그)&gt;
* </pre>
*
* <p>세그먼트는 base64<b>url</b>(패딩 없음)이라 Lucy XSS 이스케이프와 urlencode 를 모두 통과해도
* 값이 변형되지 않는다.</p>
*
* <p>RSA-OAEP 는 반드시 MGF1 해시까지 SHA-256 으로 지정해야 한다. SunJCE 는
* {@code OAEPWithSHA-256AndMGF1Padding} 만 지정하면 MGF1 에 SHA-1 을 쓰는데,
* 브라우저 Web Crypto 의 {@code RSA-OAEP + SHA-256} 은 MGF1 도 SHA-256 이라 그대로 두면
* 복호화가 실패한다.</p>
*/
@Component
public class PasswordEnvelopeCodec {
public static final String PREFIX = "ENC1.";
/** AES-GCM 인증 태그 길이(비트). Web Crypto 기본값과 동일. */
private static final int GCM_TAG_BITS = 128;
private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
/** 값이 봉투 형식인지. 파싱 비용 없이 접두사만 본다. */
public boolean isEnvelope(String value) {
return value != null && value.startsWith(PREFIX);
}
/** 봉투에서 keyId 만 추출. 형식이 어긋나면 {@code null}. */
public String keyIdOf(String value) {
String[] parts = split(value);
return parts == null ? null : parts[1];
}
/**
* RSA 개인키로 AES 키를 풀고 AES-GCM 으로 본문을 복호화한다.
*
* @throws PasswordDecryptException 형식 오류·키 불일치·태그 검증 실패
*/
public String decrypt(String value, PrivateKey privateKey) {
String[] parts = split(value);
if (parts == null) {
throw new PasswordDecryptException("봉투 형식이 올바르지 않다");
}
try {
Key aesKey = unwrapAesKey(URL_DECODER.decode(parts[2]), privateKey);
byte[] iv = URL_DECODER.decode(parts[3]);
byte[] cipherText = URL_DECODER.decode(parts[4]);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_BITS, iv));
return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
} catch (PasswordDecryptException e) {
throw e;
} catch (Exception e) {
throw new PasswordDecryptException("봉투 복호화 실패", e);
}
}
private Key unwrapAesKey(byte[] wrapped, PrivateKey privateKey) {
try {
Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
rsa.init(Cipher.DECRYPT_MODE, privateKey, new OAEPParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT));
return new SecretKeySpec(rsa.doFinal(wrapped), "AES");
} catch (Exception e) {
throw new PasswordDecryptException("AES 키 언랩 실패", e);
}
}
/** {@code ENC1.keyId.encKey.iv.cipher} 5개 세그먼트. 어긋나면 null. */
private String[] split(String value) {
if (!isEnvelope(value)) {
return null;
}
String[] parts = value.split("\\.", 5);
if (parts.length != 5) {
return null;
}
for (String part : parts) {
if (part.isEmpty()) {
return null;
}
}
return parts;
}
}
@@ -0,0 +1,57 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.Collections;
import java.util.Map;
/**
* 비밀번호 전송암호화용 공개키 발급.
*
* <p>경로를 {@code /api/**} 아래에 둔 이유: {@code PortalConfigWebDispatcherServlet#addInterceptors}
* 가 {@code /api/**} 를 인터셉터(비밀번호 변경 강제·step-up 가드)에서 제외한다. 로그인 전 익명 상태에서
* 호출되므로 {@code /api/session/csrf} 와 같은 자리에 있어야 리다이렉트에 걸리지 않는다.</p>
*
* <p>GET 이라 CSRF 대상이 아니고, 공개키만 나가므로 인증도 요구하지 않는다.</p>
*/
@RestController
@RequestMapping("/api/security")
public class PasswordKeyController {
private final PasswordCryptoProperties properties;
private final PasswordKeyStore keyStore;
public PasswordKeyController(PasswordCryptoProperties properties, PasswordKeyStore keyStore) {
this.properties = properties;
this.keyStore = keyStore;
}
@GetMapping("/password-key.json")
public ResponseEntity<Map<String, Object>> issue(HttpServletRequest request) {
if (!properties.isEnabled()) {
// 클라이언트가 조용히 평문 폴백하도록 200 + enabled:false 로 답한다.
return noStore().body(Collections.<String, Object>singletonMap("enabled", Boolean.FALSE));
}
IssuedKey key = keyStore.issue(request);
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("enabled", Boolean.TRUE);
body.put("alg", "RSA-OAEP-256");
body.put("keyId", key.getKeyId());
body.put("publicKey", key.getPublicKey());
body.put("expiresIn", key.getExpiresIn());
// REQUEST 는 1회용이라 클라이언트가 캐시하면 안 된다. 나머지는 만료까지 재사용해
// 비밀번호 검증용 ajax 가 호출마다 RSA 키쌍을 만들게 하지 않는다.
body.put("keyScope", properties.getKeyScope().name());
return noStore().body(body);
}
private ResponseEntity.BodyBuilder noStore() {
return ResponseEntity.ok().cacheControl(CacheControl.noStore());
}
}
@@ -0,0 +1,52 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.UUID;
/**
* RSA 키쌍 생성/인코딩 헬퍼. 키 보관소 구현들이 공유한다.
*/
final class PasswordKeyPairs {
private PasswordKeyPairs() {
}
static KeyPair generate(int keySize) {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(keySize);
return generator.generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("RSA 키쌍 생성 실패", e);
}
}
static String newKeyId() {
return UUID.randomUUID().toString().replace("-", "");
}
/** 브라우저 {@code crypto.subtle.importKey('spki', ...)} 가 그대로 먹는 형식. */
static String toSpkiBase64(PublicKey publicKey) {
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
}
/**
* PKCS#8 바이트 → PrivateKey. 세션 복제(직렬화) 환경을 고려해
* {@link SessionScopedPasswordKeyStore} 는 키 객체 대신 바이트를 보관한다.
*/
static PrivateKey toPrivateKey(byte[] pkcs8) {
try {
return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalStateException("RSA 개인키 복원 실패", e);
}
}
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import javax.servlet.http.HttpServletRequest;
import java.security.PrivateKey;
/**
* 비밀번호 전송암호화용 RSA 키쌍 보관소. 구현체는 {@link PasswordCryptoProperties.KeyScope} 별로 존재하며
* {@link PasswordKeyStoreRouter} 가 설정값에 따라 위임한다.
*/
public interface PasswordKeyStore {
/** 새 공개키를 발급한다(구현에 따라 기존 키 재사용). */
IssuedKey issue(HttpServletRequest request);
/** keyId 에 대응하는 개인키. 없거나 만료됐으면 {@code null}. */
PrivateKey resolve(String keyId, HttpServletRequest request);
/** 요청 처리가 끝난 뒤 호출. 1회용 키를 폐기하는 구현에서만 의미가 있다. */
void consume(String keyId, HttpServletRequest request);
}
@@ -0,0 +1,57 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.security.PrivateKey;
/**
* {@code portal.security.password-encrypt.key-scope} 설정값에 따라 실제 보관소로 위임한다.
* 설정은 yml 이므로 기동 시점에 고정된다(런타임 변경 시 재기동 필요).
*/
@Primary
@Component
public class PasswordKeyStoreRouter implements PasswordKeyStore {
private final PasswordCryptoProperties properties;
private final RequestScopedPasswordKeyStore requestScoped;
private final SessionScopedPasswordKeyStore sessionScoped;
private final ServerScopedPasswordKeyStore serverScoped;
public PasswordKeyStoreRouter(PasswordCryptoProperties properties,
RequestScopedPasswordKeyStore requestScoped,
SessionScopedPasswordKeyStore sessionScoped,
ServerScopedPasswordKeyStore serverScoped) {
this.properties = properties;
this.requestScoped = requestScoped;
this.sessionScoped = sessionScoped;
this.serverScoped = serverScoped;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
return delegate().issue(request);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
return delegate().resolve(keyId, request);
}
@Override
public void consume(String keyId, HttpServletRequest request) {
delegate().consume(keyId, request);
}
private PasswordKeyStore delegate() {
PasswordCryptoProperties.KeyScope scope = properties.getKeyScope();
if (scope == PasswordCryptoProperties.KeyScope.SESSION) {
return sessionScoped;
}
if (scope == PasswordCryptoProperties.KeyScope.SERVER) {
return serverScoped;
}
return requestScoped;
}
}
@@ -0,0 +1,22 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
/**
* 비밀번호 계열 파라미터명 판별. {@code plaintext-policy} 가 "평문으로 오면 안 되는 파라미터"를 가리는 데 쓴다.
*
* <p>실제 사용 중인 이름은 {@code password}, {@code password2}, {@code confirmPassword},
* {@code newPassword}, {@code currentPassword}, {@code inputPassword} 로 모두 "password" 를 포함한다.
* 향후 축약형이 생길 것을 대비해 {@code passwd}/{@code pwd} 도 함께 본다.</p>
*/
final class PasswordParamNames {
private PasswordParamNames() {
}
static boolean isPasswordLike(String name) {
if (name == null) {
return false;
}
String lower = name.toLowerCase();
return lower.contains("password") || lower.contains("passwd") || lower.contains("pwd");
}
}
@@ -0,0 +1,90 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 폼 진입마다 1회용 키를 발급하고 요청 1회 사용 후 폐기하는 보관소({@code key-scope: REQUEST}).
*
* <p>같은 keyId 로 두 번 복호화할 수 없으므로 재전송(replay) 공격이 차단된다. 개인키가 인스턴스
* 로컬 메모리에만 있으므로 WebLogic 다중 인스턴스에서는 스티키 세션이 전제다(현재 CSRF·단일세션
* 강제가 이미 세션 고정을 전제한다).</p>
*/
@Slf4j
@Component
public class RequestScopedPasswordKeyStore implements PasswordKeyStore {
/** 메모리 폭주 방지 상한. 초과 시 만료 스윕 후에도 남으면 발급을 거절하지 않고 가장 오래된 것부터 버린다. */
private static final int MAX_ENTRIES = 20_000;
private final PasswordCryptoProperties properties;
private final Map<String, Entry> entries = new ConcurrentHashMap<>();
public RequestScopedPasswordKeyStore(PasswordCryptoProperties properties) {
this.properties = properties;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
sweep();
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
String keyId = PasswordKeyPairs.newKeyId();
int ttl = properties.getKeyTtlSeconds();
entries.put(keyId, new Entry(keyPair.getPrivate(), System.currentTimeMillis() + ttl * 1000L));
return new IssuedKey(keyId, PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()), ttl);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
Entry entry = entries.get(keyId);
if (entry == null) {
return null;
}
if (entry.expiresAt < System.currentTimeMillis()) {
entries.remove(keyId);
return null;
}
return entry.privateKey;
}
@Override
public void consume(String keyId, HttpServletRequest request) {
entries.remove(keyId);
}
/** 만료 항목 정리. 발급 시점에만 돌리므로 별도 스케줄러가 필요 없다. */
private void sweep() {
long now = System.currentTimeMillis();
Iterator<Map.Entry<String, Entry>> it = entries.entrySet().iterator();
while (it.hasNext()) {
if (it.next().getValue().expiresAt < now) {
it.remove();
}
}
if (entries.size() >= MAX_ENTRIES) {
log.warn("비밀번호 전송암호화 키 보관소 상한 초과 - size={}, 오래된 항목을 버린다", entries.size());
Iterator<Map.Entry<String, Entry>> overflow = entries.entrySet().iterator();
while (overflow.hasNext() && entries.size() >= MAX_ENTRIES) {
overflow.next();
overflow.remove();
}
}
}
private static final class Entry {
private final PrivateKey privateKey;
private final long expiresAt;
private Entry(PrivateKey privateKey, long expiresAt) {
this.privateKey = privateKey;
this.expiresAt = expiresAt;
}
}
}
@@ -0,0 +1,31 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import javax.servlet.http.HttpServletRequest;
/**
* 요청이 TLS 구간으로 들어왔는지 판정한다. 경고 로그 문구를 가르는 용도다.
*
* <p>{@code request.isSecure()} 만 봐서는 안 된다. {@code PasswordDecryptFilter} 는 Lucy XSS 필터보다
* 먼저 돌아야 해서 order 가 {@code Integer.MIN_VALUE} 인데, {@code ForwardedHeaderFilter}
* ({@code PortalConfigForwardedHeader}) 도 {@code Ordered.HIGHEST_PRECEDENCE} = 같은 값이라
* 둘의 상대 순서가 보장되지 않는다. 즉 이 시점의 {@code isSecure()} 는 {@code X-Forwarded-Proto}
* 교정 <b>전</b> 값일 수 있다.</p>
*
* <p>그래서 헤더를 직접 본다. 앞단 프록시가 신뢰 경계 안이라는 전제는 {@code ForwardedHeaderFilter} 와 같다.</p>
*/
final class RequestTransport {
private RequestTransport() {
}
static boolean isSecure(HttpServletRequest request) {
String proto = request.getHeader("X-Forwarded-Proto");
if (proto != null && !proto.isEmpty()) {
// 프록시가 여러 단이면 "https, http" 처럼 쌓인다. 클라이언트에 가장 가까운 첫 값이 기준이다.
int comma = proto.indexOf(',');
String first = (comma >= 0 ? proto.substring(0, comma) : proto).trim();
return "https".equalsIgnoreCase(first);
}
return request.isSecure();
}
}
@@ -0,0 +1,81 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.security.KeyPair;
import java.security.PrivateKey;
/**
* 서버 고정 키쌍 + TTL 로테이션 구현({@code key-scope: SERVER}).
*
* <p>직전 키를 1개 유예 보관해, 로테이션 순간에 이미 공개키를 받아 간 폼이 제출돼도 복호화된다.
* 재전송 방지 수단은 없으므로(같은 봉투를 여러 번 보내도 복호화됨) 인증 실패횟수 제한·계정 잠금에
* 의존한다. 무상태라 다중 인스턴스에서도 각자 동작하지만, 인스턴스마다 키가 달라
* 공개키 발급과 폼 제출이 같은 인스턴스로 가야 한다.</p>
*/
@Component
public class ServerScopedPasswordKeyStore implements PasswordKeyStore {
private final PasswordCryptoProperties properties;
private volatile Holder current;
private volatile Holder previous;
public ServerScopedPasswordKeyStore(PasswordCryptoProperties properties) {
this.properties = properties;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
Holder holder = currentHolder();
int remaining = (int) Math.max(1, (holder.expiresAt - System.currentTimeMillis()) / 1000L);
return new IssuedKey(holder.keyId, holder.publicKeySpki, remaining);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
Holder holder = current;
if (holder != null && holder.keyId.equals(keyId)) {
return holder.privateKey;
}
Holder old = previous;
if (old != null && old.keyId.equals(keyId)) {
return old.privateKey;
}
return null;
}
@Override
public void consume(String keyId, HttpServletRequest request) {
// 고정 키라 폐기하지 않는다.
}
private synchronized Holder currentHolder() {
long now = System.currentTimeMillis();
if (current == null || current.expiresAt < now) {
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
previous = current;
current = new Holder(
PasswordKeyPairs.newKeyId(),
keyPair.getPrivate(),
PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()),
now + properties.getKeyTtlSeconds() * 1000L);
}
return current;
}
private static final class Holder {
private final String keyId;
private final PrivateKey privateKey;
private final String publicKeySpki;
private final long expiresAt;
private Holder(String keyId, PrivateKey privateKey, String publicKeySpki, long expiresAt) {
this.keyId = keyId;
this.privateKey = privateKey;
this.publicKeySpki = publicKeySpki;
this.expiresAt = expiresAt;
}
}
}
@@ -0,0 +1,84 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.io.Serializable;
import java.security.KeyPair;
import java.security.PrivateKey;
/**
* 세션 단위로 키쌍을 보관하는 구현({@code key-scope: SESSION}).
*
* <p>개인키를 객체가 아니라 PKCS#8 바이트로 들고 있어 세션 복제(직렬화)에도 안전하다.
* 같은 keyId 가 세션 수명 동안 재사용되므로 재전송 방지는 IV 랜덤성과 애플리케이션의
* 인증 실패횟수 제한에 의존한다.</p>
*/
@Component
public class SessionScopedPasswordKeyStore implements PasswordKeyStore {
private static final String SESSION_ATTR = "DJB_PWD_CRYPTO_KEY";
private final PasswordCryptoProperties properties;
public SessionScopedPasswordKeyStore(PasswordCryptoProperties properties) {
this.properties = properties;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
HttpSession session = request.getSession(true);
Holder holder = (Holder) session.getAttribute(SESSION_ATTR);
long now = System.currentTimeMillis();
if (holder == null || holder.expiresAt < now) {
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
holder = new Holder(
PasswordKeyPairs.newKeyId(),
keyPair.getPrivate().getEncoded(),
PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()),
now + properties.getKeyTtlSeconds() * 1000L);
session.setAttribute(SESSION_ATTR, holder);
}
int remaining = (int) Math.max(1, (holder.expiresAt - now) / 1000L);
return new IssuedKey(holder.keyId, holder.publicKeySpki, remaining);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
Holder holder = (Holder) session.getAttribute(SESSION_ATTR);
if (holder == null || !holder.keyId.equals(keyId)) {
return null;
}
if (holder.expiresAt < System.currentTimeMillis()) {
session.removeAttribute(SESSION_ATTR);
return null;
}
return PasswordKeyPairs.toPrivateKey(holder.privateKeyPkcs8);
}
@Override
public void consume(String keyId, HttpServletRequest request) {
// 세션 수명 동안 재사용한다.
}
private static final class Holder implements Serializable {
private static final long serialVersionUID = 1L;
private final String keyId;
private final byte[] privateKeyPkcs8;
private final String publicKeySpki;
private final long expiresAt;
private Holder(String keyId, byte[] privateKeyPkcs8, String publicKeySpki, long expiresAt) {
this.keyId = keyId;
this.privateKeyPkcs8 = privateKeyPkcs8;
this.publicKeySpki = publicKeySpki;
this.expiresAt = expiresAt;
}
}
}
@@ -1,18 +1,5 @@
package com.eactive.apim.portal.common.util;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component("encryptionUtil")
public class EncryptionUtil {
public static String generateNewPassword() {
@@ -53,40 +40,4 @@ public class EncryptionUtil {
return newpassword.toString();
}
@Value("${encryption.key:kjbank_portal_application_1357902}")
private String secretKey; // Should be 16, 24, or 32 bytes long for AES-128, AES-192, or AES-256
private static final String ALGORITHM = "AES";
private SecretKeySpec createSecretKey() {
byte[] key = secretKey.getBytes(StandardCharsets.UTF_8);
return new SecretKeySpec(key, ALGORITHM);
}
public String encrypt(String value) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
if (value == null || value.isEmpty()) {
return value;
}
SecretKeySpec key = createSecretKey();
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] encryptedBytes = cipher.doFinal(value.getBytes());
return Base64.getEncoder().encodeToString(encryptedBytes);
}
public String decrypt(String encrypted) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
if (encrypted == null || encrypted.isEmpty()) {
return encrypted;
}
SecretKeySpec key = createSecretKey();
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, key);
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encrypted));
return new String(decryptedBytes);
}
}
@@ -172,6 +172,50 @@ public class StringMaskingUtil {
return value;
}
/** 요청 파라미터 로깅 시 값을 통째로 가릴 키. 부분일치(소문자)로 본다. */
private static final String[] SENSITIVE_PARAM_KEYWORDS = {
"password", "passwd", "pwd", "secret", "credential", "token"};
/**
* 요청 파라미터 맵을 로그용 문자열로 만든다. 비밀번호·시크릿·토큰 계열 키는 값을 {@code [****]} 로 가린다.
*
* <p>비밀번호 전송암호화가 켜져 있어도 이 시점의 파라미터는 이미 복호화된 평문이므로,
* 마스킹 없이 로깅하면 암호화 조치가 무의미해진다.</p>
*
* <pre>id=[user@a.com], password=[****]</pre>
*/
public static String maskParameterMap(java.util.Map<String, String[]> parameterMap) {
if (parameterMap == null || parameterMap.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
for (java.util.Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(entry.getKey()).append('=');
if (isSensitiveParamName(entry.getKey())) {
sb.append("[****]");
} else {
sb.append(Arrays.toString(entry.getValue()));
}
}
return sb.toString();
}
private static boolean isSensitiveParamName(String name) {
if (!isValidString(name)) {
return false;
}
String lower = name.toLowerCase();
for (String keyword : SENSITIVE_PARAM_KEYWORDS) {
if (lower.contains(keyword)) {
return true;
}
}
return false;
}
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
@@ -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<CellPhone, String
}
// 기존의 prefix 체크 로직 유지
return StringUtils.startsWith(cellPhone, "+821") ||
StringUtils.startsWith(cellPhone, "821") ||
StringUtils.startsWith(cellPhone, "010") ||
StringUtils.startsWith(cellPhone, "011") ||
StringUtils.startsWith(cellPhone, "016") ||
StringUtils.startsWith(cellPhone, "017") ||
StringUtils.startsWith(cellPhone, "018") ||
StringUtils.startsWith(cellPhone, "019");
return Strings.CS.startsWithAny(cellPhone,
"+821", "821", "010", "011", "016", "017", "018", "019");
}
private boolean isValidPartLength(String prefix, String middle, String last) {
@@ -2,6 +2,8 @@ package com.eactive.apim.portal.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.fileupload.FileItemFactory;
import org.apache.commons.fileupload.FileUpload;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.multipart.commons.CommonsMultipartResolver;
@@ -18,6 +20,18 @@ import org.springframework.web.multipart.commons.CommonsMultipartResolver;
@RequiredArgsConstructor
public class MultipartConfig {
/**
* 멀티파트 파트 1개의 헤더 구간 최대 바이트.
* <p>
* 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배 엄격하다.)
* </p>
*/
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;
}
@@ -2,11 +2,14 @@ package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
import com.eactive.apim.portal.common.security.passwordcrypto.DecryptingMultipartRequestWrapper;
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordDecryptFilter;
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
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 +19,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;
@@ -62,12 +66,41 @@ public class PortalConfigSecurity {
return registration;
}
/**
* 비밀번호 전송암호화 복호화 필터.
*
* <p>order {@code MIN_VALUE + 2} — {@code PortalConfigWebDispatcherServlet} 의
* MultipartFilter({@code MIN_VALUE + 1}) <b>이후</b>, 아래 Lucy XSS 필터
* ({@code MIN_VALUE + 3}) <b>이전</b>에 실행되어야 한다.</p>
*
* <p>MultipartFilter 이후여야 하는 이유: 파일 업로드가 동반된 폼(예: 법인가입의 사업자등록증
* 첨부)은 {@code multipart/form-data} 로 제출되는데, Spring 의 {@code MultipartHttpServletRequest}
* 는 폼 필드 값을 자체 파싱해 {@code getParameter()} 에서 곧바로 반환하고 감싸고 있는 원본 요청으로
* 위임하지 않는다. 이 필터가 MultipartFilter 보다 먼저 실행되어(즉 더 안쪽에서) 요청을 감싸면,
* 멀티파트 안의 비밀번호 파라미터는 이 필터의 복호화 로직을 거치지 않고 봉투(ENC1) 문자열 그대로
* 컨트롤러까지 전달된다({@link DecryptingMultipartRequestWrapper} 주석 참고).</p>
*
* <p>Lucy XSS 필터 이전이어야 하는 이유: 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거쳐야
* 특수문자가 든 비밀번호의 해시 비교 결과가 지금과 동일하게 유지된다. 이 순서를 뒤집으면 기존
* 계정 로그인이 깨진다.</p>
*/
@Bean
public FilterRegistrationBean<PasswordDecryptFilter> passwordDecryptFilterRegistration(
PasswordDecryptFilter filter) {
FilterRegistrationBean<PasswordDecryptFilter> registrationBean = new FilterRegistrationBean<>(filter);
registrationBean.setOrder(Integer.MIN_VALUE + 2);
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
// order MIN_VALUE + 3 — 위 PasswordDecryptFilter(MIN_VALUE + 2) 다음에 실행되어야
// 복호화된 평문이 XSS 이스케이프를 거친다(순서를 뒤집으면 기존 계정 로그인이 깨짐).
@Bean
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
XssEscapeServletFilter xssEscapeServletFilter = new XssEscapeServletFilter();
registrationBean.setFilter(xssEscapeServletFilter);
registrationBean.setOrder(Integer.MIN_VALUE + 1);
registrationBean.setOrder(Integer.MIN_VALUE + 3);
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
@@ -92,6 +125,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<HeaderWriterFilter>() {
@Override
public <O extends HeaderWriterFilter> O postProcess(O filter) {
filter.setShouldWriteHeadersEagerly(true);
return filter;
}
}))
.formLogin(form -> form
.loginPage("/login")
.usernameParameter("id")
@@ -175,31 +175,44 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
*
* <p>버전닝이 꺼져 있으면(prod 포함 — {@link #isResourceVersioningEnabled()} 참고) URL 이
* 고정이라 장기 {@code max-age} 캐시를 쓰면 배포 후에도 브라우저가 옛 내용을 계속 쓸 위험이 있다.
* 이 경우 {@code no-cache}(매 요청 {@code If-Modified-Since} 조건부 재검증, 변경 없으면 304)로
* 신선도를 보장한다 — OHS 가 정적 리소스를 직접 서빙해도 Apache 가 파일 mtime 기준으로 동일하게
* Last-Modified/조건부 GET 을 처리하므로 WAS 와 동작이 어긋나지 않는다
* (배경: djb-obsidian-docs/3000-테스트/15-Static리소스분리).</p>
*/
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
// no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
CacheControl cacheControl = isResourceCachingEnabled()
? CacheControl.maxAge(1, TimeUnit.DAYS)
: CacheControl.noStore();
boolean versioningOn = isResourceVersioningEnabled();
CacheControl cacheControl;
if (!isResourceCachingEnabled()) {
// no-store: 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다(dev/local).
cacheControl = CacheControl.noStore();
} else if (versioningOn) {
cacheControl = CacheControl.maxAge(1, TimeUnit.DAYS);
} else {
cacheControl = CacheControl.noCache();
}
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
.addResourceLocations(locations)
.setCacheControl(cacheControl)
.resourceChain(isResourceCachingEnabled());
if (isResourceVersioningEnabled()) {
if (versioningOn) {
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
}
chain.addResolver(new PathResourceResolver());
}
/**
* 정적자원 해시 버전닝 활성 여부. prod 프로파일은 토글과 무관하게 항상 ON,
* 그 외 프로파일은 {@code app.resource-versioning.enabled} 값을 따른다.
* 정적자원 해시 버전닝 활성 여부. {@code app.resource-versioning.enabled} 값을 그대로 따른다.
*
* <p>과거 prod 프로파일은 이 토글과 무관하게 항상 ON 이었으나, OHS 가 정적 리소스를 직접
* 서빙하는 구조로 바뀌면서 OFF 로 전환했다 — 해시가 붙은 URL(main-&lt;hash&gt;.css)이 OHS
* 디스크의 무해시 원본과 어긋나 404 가 날 수 있어서다(prod.yml 에서 명시적으로 false 설정).
* 대신 캐시 신선도는 {@link #addStaticResourceHandler} 의 no-cache 조건부 재검증으로 보장한다.</p>
*/
private boolean isResourceVersioningEnabled() {
if (environment.acceptsProfiles(Profiles.of("prod"))) {
return true;
}
return resourceVersioningEnabled;
}
@@ -24,11 +24,11 @@ public class RestTemplateConfig {
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
// 연결 타임아웃: 10초
factory.setConnectTimeout(10000);
// 연결 타임아웃: 5초 (admin base-url failover 시 다음 후보로 빨리 넘어가기 위함)
factory.setConnectTimeout(5000);
// 읽기 타임아웃: 30
factory.setReadTimeout(30000);
// 읽기 타임아웃: 5
factory.setReadTimeout(5000);
return new RestTemplate(factory);
}
@@ -44,7 +44,7 @@ import java.util.stream.Collectors;
*/
@Slf4j
@Controller
@RequestMapping("//notitest")
@RequestMapping("/djb/notitest")
@RequiredArgsConstructor
public class NotiTestController {
+6
View File
@@ -40,6 +40,12 @@ gateway:
portal:
# auth-virtual-code: 654321
security:
# 개발 서버는 HTTP(비 secure context)라 crypto.subtle 이 없다. software-fallback(forge)으로 암호화한다.
password-encrypt:
enabled: true
# 개발 서버는 원격 오리진 HTTP → crypto.subtle 이 없다. 기본값인 software-fallback(forge)으로 암호화된다.
plaintext-policy: permissive
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true
+17 -4
View File
@@ -9,9 +9,11 @@ spring:
cachecontrol:
max-age: 86400
public: true
# 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
# 정적자원 콘텐츠 해시 버전닝: OHS 가 정적 리소스(css/js/img 등)를 직접 서빙하는 구조에서
# 해시 URL(main-<hash>.css)이 OHS 디스크의 무해시 원본과 어긋나 404 위험이 있어 OFF 로 전환했다
# (아래 app.resource-versioning.enabled: false, PortalConfigWebDispatcherServlet 참고).
# 캐시 무효화는 대신 no-cache(매 요청 If-Modified-Since 조건부 재검증)로 보장한다.
# 배경: djb-obsidian-docs/3000-테스트/15-Static리소스분리
jpa:
properties:
hibernate:
@@ -28,7 +30,7 @@ spring:
app:
resource-versioning:
enabled: true
enabled: false
resource-caching:
enabled: true
@@ -37,3 +39,14 @@ server:
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
forward-headers-strategy: framework
portal:
security:
# 비밀번호 전송암호화. HTTPS 구간이라 켠다.
password-encrypt:
enabled: true
# 운영은 평문 비밀번호를 받지 않는다. 봉투가 아니면 빈 값으로 치환돼 인증 실패로 떨어진다.
# 대가: JS 를 끈 브라우저는 로그인할 수 없고, 공개키 발급(/api/security/password-key.json)이
# 죽으면 로그인 전체가 막힌다(permissive 면 평문으로 degrade 되어 로그인은 됐을 상황).
# 되돌리려면 이 값을 permissive 로 바꾸고 재기동해야 한다 — yml 이라 무중단 토글은 안 된다.
plaintext-policy: enforce
+5
View File
@@ -22,6 +22,11 @@ spring:
portal:
# 검증 단계에선 사용하지 않음
# auth-virtual-code: 654321
security:
# HTTPS 구간이라 전송암호화를 켠다. plaintext-policy 승격(ENFORCE)은 운영 승격 전 검증 후 판단.
password-encrypt:
enabled: true
plaintext-policy: permissive
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true
+48
View File
@@ -153,6 +153,54 @@ portal:
user-approval: true
password-expiration-days: 90
security:
# 비밀번호 전송암호화(RSA-OAEP + AES-GCM). 개발자도구 Network 탭 평문 노출 대응.
#
# 화면(password-crypto.js)이 서버 공개키로 AES 키를 감싸고 비밀번호를 그 AES 키로 암호화해
# ENC1.<keyId>.<랩된AES키>.<iv>.<암호문+태그> 봉투로 보낸다. 서버는 PasswordDecryptFilter 가
# 파라미터를 평문으로 되돌리므로 컨트롤러는 이 기능을 몰라도 된다.
#
# 주의: XSS 방어도 MITM 방어도 아니다. 스크립트가 주입되면 입력창에서 직접 털리고,
# HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다.
#
# 아래는 모든 키의 기본값이다. 프로파일 yml 에는 이 값과 다른 것만 적는다.
password-encrypt:
# 마스터 스위치. false 면 필터·키 발급 엔드포인트가 모두 무동작이고 화면은 평문 전송한다.
# 기본은 꺼둔다 — 켜는 것은 프로파일 yml 의 판단.
enabled: false
# RSA 개인키 보관 범위.
# REQUEST - 폼 진입마다 1회용 키 발급, 요청 1회 사용 후 폐기. 재전송 차단이 가장 강하다.
# SESSION - 세션 단위 보관. 세션 복제 환경에 유리.
# SERVER - 서버 고정 키쌍 + TTL 로테이션. 무상태라 단순하지만 재전송 방지 수단이 없다.
key-scope: REQUEST
# 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기.
key-ttl-seconds: 300
# 봉투가 씌워지지 않은 '평문 비밀번호 파라미터' 를 서버가 어떻게 다룰지.
# 전송 구간(HTTP/HTTPS)을 막는 스위치가 아니다 — 전송 구간은 경고 문구에만 영향을 준다.
# none - 무동작. 평문을 그대로 받는다.
# permissive - 평문을 받되 서버 로그에 경고를 남기고(요청당 1줄), 화면에도 경고 팝업을 띄운다.
# HTTP 접속이면 비밀번호 입력 화면에서 세션당 1회 팝업이 뜬다.
# enforce - 평문 비밀번호를 거부한다(빈 값 치환 → 인증 실패).
# JS 를 끈 브라우저는 로그인하지 못하므로, permissive 로 운영하며 위 경고 로그가
# 안 나오는 것을 확인한 뒤에 승격한다.
# 기본은 none — 어느 환경에 올려도 기존 동작이 바뀌지 않게 한다.
# (enabled: false 면 필터가 통째로 무동작이라 이 값은 어차피 영향이 없다.)
plaintext-policy: none
# crypto.subtle 을 쓸 수 없는 환경에서 순수 JS 구현(forge)으로 봉투를 만들지 여부.
# 브라우저는 secure context(HTTPS·localhost)가 아니면 crypto.subtle 을 아예 노출하지 않는다.
# 즉 원격 오리진 HTTP(사내 IP 접속 등)에서는 이 값을 켜야 암호화가 걸린다.
# forge 번들(js/lib/forge-crypto.min.js, gzip 약 31KB)은 crypto.subtle 이 없을 때만 동적으로
# 내려가므로 HTTPS 구간에서는 전송 바이트가 0 이다. 사실상 킬스위치.
# 기본은 true — HTTPS 구간에서는 어차피 내려가지 않고, HTTP 구간에서는 켜져 있어야 의미가 있다.
software-fallback: true
# RSA 키 길이.
rsa-key-size: 2048
pages:
- path-pattern: /dashboard/daily_usage
method: GET
+109 -55
View File
@@ -1247,7 +1247,7 @@ hr {
transition: all 0.3s ease;
}
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
background: rgb(0%, 25.7647058824%, 63.5294117647%);
background: rgb(0, 65.7, 162);
}
.mobile-drawer .drawer-welcome.authenticated {
flex-direction: row;
@@ -2574,7 +2574,7 @@ hr {
color: #FFFFFF;
}
.btn-success:hover {
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
transform: translateY(-3px);
}
.btn-danger {
@@ -2582,7 +2582,7 @@ hr {
color: #FFFFFF;
}
.btn-danger:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
transform: translateY(-3px);
}
.btn-ghost {
@@ -2848,7 +2848,7 @@ hr {
.action-btn-delete:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(100%, 34.862745098%, 34.862745098%);
background: rgb(255, 88.9, 88.9);
}
.action-btn-delete:active {
transform: translateY(0);
@@ -2966,7 +2966,7 @@ hr {
background: #a4d6ea;
}
.btn-input-action.btn-change:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
}
@@ -3041,7 +3041,7 @@ hr {
border: none;
}
.btn-action-primary:hover {
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
transform: translateY(-2px);
color: #fff;
}
@@ -3091,7 +3091,7 @@ hr {
}
.status-badge.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
color: rgb(221.2, 177.8721649485, 0);
}
.status-badge.status-failed {
background: rgba(255, 107, 107, 0.1);
@@ -3131,7 +3131,7 @@ hr {
}
.status-badge-header.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
color: rgb(221.2, 177.8721649485, 0);
}
.badge-sm {
@@ -4319,7 +4319,7 @@ select.form-control {
.file-upload-wrapper .file-remove-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(100%, 34.862745098%, 34.862745098%);
background: rgb(255, 88.9, 88.9);
}
.file-upload-wrapper .file-remove-btn:active {
transform: translateY(0);
@@ -4640,7 +4640,7 @@ select.form-control {
transition: all 0.3s ease;
}
.form-actions--with-withdrawal .withdrawal-link:hover {
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
}
.form-actions--with-withdrawal .withdrawal-link img {
width: 22px;
@@ -4795,7 +4795,7 @@ select.form-control {
text-decoration: underline;
}
.notice-content-box a:hover {
color: rgb(0%, 25.7647058824%, 63.5294117647%);
color: rgb(0, 65.7, 162);
}
.form-row--content .form-label-wrapper {
@@ -5067,6 +5067,8 @@ select.form-control {
color: #212529;
font-size: 20px;
line-height: 1.6;
word-break: keep-all;
overflow-wrap: break-word;
}
.modal-body p {
margin: 0;
@@ -5733,7 +5735,7 @@ select.form-control {
font-size: 16px;
}
.drawer-logout-btn:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
}
@@ -6447,7 +6449,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--default:hover {
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
}
.list-table-btn--primary {
background-color: #ecf0fa;
@@ -6455,7 +6457,7 @@ select.form-control {
color: #2a69de;
}
.list-table-btn--primary:hover {
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
background-color: rgb(216.7625, 224.8125, 244.9375);
}
.list-table-btn--secondary {
background-color: #f5f5f4;
@@ -6463,7 +6465,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--secondary:hover {
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
}
.list-table-btn--danger {
background-color: #fbe7e9;
@@ -6471,7 +6473,7 @@ select.form-control {
color: #bb1026;
}
.list-table-btn--danger:hover {
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
}
.table-pagination {
@@ -7121,7 +7123,7 @@ select.form-control {
.alert.alert-error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: rgb(100%, 27.7647058824%, 27.7647058824%);
color: rgb(255, 70.8, 70.8);
align-items: center;
}
.alert.alert-error svg {
@@ -7135,7 +7137,7 @@ select.form-control {
.alert.alert-success {
background: rgba(107, 207, 127, 0.1);
border: 1px solid rgba(107, 207, 127, 0.3);
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
}
.alert.alert-info {
background: rgba(0, 73, 180, 0.1);
@@ -11549,10 +11551,10 @@ body.index-page-body {
line-height: 20px;
}
.login-button:hover {
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
background: rgb(25.65, 70.3, 173.85);
}
.login-button:active {
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
background: rgb(24.3, 66.6, 164.7);
}
.login-button:disabled {
opacity: 0.6;
@@ -11595,10 +11597,10 @@ body.index-page-body {
border-bottom-right-radius: 8px;
}
.login-links-container .link-btn:hover {
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
background: rgb(220.61, 227.85, 245.95);
}
.login-links-container .link-btn:active {
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
background: rgb(205.22, 215.7, 241.9);
}
.login-alert {
@@ -12107,12 +12109,12 @@ body.index-page-body {
}
.auth-request-button:hover,
.auth-verify-button:hover {
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
transform: none !important;
}
.auth-request-button:active,
.auth-verify-button:active {
background: rgb(8.3967014843%, 57.3774601429%, 91.4307494961%);
background: rgb(21.411588785, 146.3125233645, 233.148411215);
}
.auth-request-button:disabled,
.auth-verify-button:disabled {
@@ -12161,10 +12163,10 @@ body.index-page-body {
background: #f0f2f5;
}
.account-recovery-card .form-actions .cancel-button:hover {
background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%);
background: rgb(225.45, 229.39, 235.3);
}
.account-recovery-card .form-actions .cancel-button:active {
background: rgb(82.7058823529%, 85.0117647059%, 88.4705882353%);
background: rgb(210.9, 216.78, 225.6);
}
.account-recovery-card .form-actions .submit-button {
color: #FFFFFF;
@@ -12174,7 +12176,7 @@ body.index-page-body {
background: rgb(6, 54, 125);
}
.account-recovery-card .form-actions .submit-button:active {
background: rgb(0%, 25.7647058824%, 63.5294117647%);
background: rgb(0, 65.7, 162);
}
.account-recovery-card .form-actions .submit-button:disabled {
opacity: 0.6;
@@ -12410,7 +12412,7 @@ body.index-page-body {
transition: color 0.3s ease;
}
.result-info-box .info-text .info-link:hover {
color: rgb(0%, 25.7647058824%, 63.5294117647%);
color: rgb(0, 65.7, 162);
}
@media (max-width: 576px) {
.result-info-box .info-text {
@@ -17610,7 +17612,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-copy-action:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
}
@media (max-width: 768px) {
.btn-copy-action {
@@ -17637,7 +17639,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-view-secret:hover {
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
}
.btn-view-secret svg {
width: 20px;
@@ -17822,7 +17824,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: 8px;
}
.btn-copy-action:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
}
.btn-view-secret {
width: 100% !important;
@@ -17838,7 +17840,7 @@ input[type=checkbox]:checked + .custom-checkbox {
height: 16px;
}
.btn-view-secret:hover {
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
}
#revealedSecretBox {
width: 100%;
@@ -18111,7 +18113,7 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-shrink: 0;
}
.detail-wrap .dt-btn-copy:hover {
background: rgb(74.3529411765%, 90.2296918768%, 100%);
background: rgb(189.6, 230.0857142857, 255);
}
.detail-wrap .dt-btn-copy svg {
color: #2a69de;
@@ -18292,7 +18294,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-gray:hover {
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
}
.detail-wrap .dt-btn-red {
width: 156px;
@@ -18310,7 +18312,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-red:hover {
background: rgb(100%, 27.4868759774%, 25.1921568627%);
background: rgb(255, 70.0915337423, 64.24);
}
.detail-wrap .dt-btn-blue {
width: 156px;
@@ -19884,7 +19886,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-list:hover {
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
}
.btn-inquiry-list:active {
transform: scale(0.98);
@@ -19917,7 +19919,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-edit:hover {
background: rgb(0%, 27.1960784314%, 67.0588235294%);
background: rgb(0, 69.35, 171);
}
.btn-inquiry-edit:active {
transform: scale(0.98);
@@ -19950,7 +19952,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-delete:hover {
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
}
.btn-inquiry-delete:active {
transform: scale(0.98);
@@ -20022,7 +20024,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-left: 8px;
}
.file-upload-inline .btn-remove-file-inline:hover {
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
}
.file-upload-inline .btn-remove-file-inline svg {
width: 12px;
@@ -20054,7 +20056,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.file-upload-inline .btn-file-attach:hover {
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
}
.file-upload-inline .btn-file-attach svg {
width: 22px;
@@ -20114,7 +20116,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-secondary:hover {
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
}
.inquiry-form-container .form-actions .btn-primary {
background: #0049b4;
@@ -20122,7 +20124,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-primary:hover {
background: rgb(0%, 27.1960784314%, 67.0588235294%);
background: rgb(0, 69.35, 171);
}
.inquiry-form-container .file-upload-inline .file-input-display {
min-height: 50px;
@@ -20911,7 +20913,7 @@ input[type=checkbox]:checked + .custom-checkbox {
cursor: pointer;
}
.djb-board-write-container .form-actions .btn-submit:hover {
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
}
@media (max-width: 768px) {
.djb-board-write-container .form-actions .btn-submit {
@@ -21447,7 +21449,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: all 0.3s ease;
}
.org-file-remove:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
}
.org-file-notice {
@@ -22488,7 +22490,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.status-indicator.status-active {
background-color: rgba(107, 207, 127, 0.1);
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
}
.status-indicator.status-active .status-dot {
background-color: #6BCF7F;
@@ -25273,6 +25275,34 @@ input[type=checkbox]:checked + .custom-checkbox {
color: #666;
}
.api-statistics-container .statistics-date-range {
width: 260px;
max-width: 100%;
cursor: pointer;
}
.api-statistics-container .statistics-date-range:focus-visible {
outline: 2px solid #0049B4;
outline-offset: 2px;
border-radius: 6px;
}
.daterangepicker.statistics-date-picker {
z-index: 1100;
max-width: calc(100vw - 16px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.daterangepicker.statistics-date-picker .applyBtn {
color: #fff;
background: #0049B4;
border-radius: 4px;
}
@media (max-width: 560px) {
.daterangepicker.statistics-date-picker {
max-height: 70vh;
overflow-y: auto;
}
}
.btn-search {
background: #0049B4;
border: none;
@@ -27135,7 +27165,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.api-status .as-alert-card .as-tl-row {
display: grid;
grid-template-columns: 60px 76px 1fr;
grid-template-columns: 60px 76px 64px 1fr;
gap: 12px;
font-size: 13px;
}
@@ -27143,6 +27173,37 @@ input[type=checkbox]:checked + .custom-checkbox {
color: var(--as-muted);
font-variant-numeric: tabular-nums;
}
.api-status .as-alert-card .as-tl-row .as-tl-state {
font-size: 11px;
font-weight: 500;
text-align: center;
height: -webkit-fit-content;
height: fit-content;
padding: 2px 8px;
border-radius: var(--as-pill);
background: var(--as-gray-bg);
color: var(--as-text-2);
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-INVESTIGATING {
background: var(--as-err-bg);
color: var(--as-err);
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-IDENTIFIED {
background: #fbe9d7;
color: #c95a0f;
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-MONITORING {
background: var(--as-warn-bg);
color: var(--as-warn);
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-RESOLVED {
background: var(--as-ok-bg);
color: var(--as-ok);
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-CANCELED {
background: var(--as-gray-bg);
color: var(--as-muted);
}
.api-status .as-alert-card .as-tl-row .as-tl-who {
font-size: 11px;
font-weight: 500;
@@ -27292,9 +27353,6 @@ input[type=checkbox]:checked + .custom-checkbox {
padding: 22px 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.api-status .as-maint-card.is-ongoing {
border-left-color: var(--as-warn);
}
.api-status .as-maint-card .as-maint-head {
display: flex;
align-items: baseline;
@@ -27306,7 +27364,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--as-text);
color: var(--as-info);
}
.api-status .as-maint-card .as-schedule {
display: inline-flex;
@@ -27320,10 +27378,6 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: var(--as-pill);
font-variant-numeric: tabular-nums;
}
.api-status .as-maint-card.is-ongoing .as-schedule {
color: var(--as-warn);
background: var(--as-warn-bg);
}
.api-status .as-maint-card .as-maint-body {
margin: 14px 0 12px;
font-size: 14px;
@@ -27720,7 +27774,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-bottom: 12px;
}
.api-status .as-alert-card .as-tl-row {
grid-template-columns: 52px 1fr;
grid-template-columns: 52px 68px 1fr;
}
.api-status .as-alert-card .as-tl-who {
display: none;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

-491
View File
@@ -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 = `
<div class="modal fade" id="chart_editor_modal" tabindex="-1" aria-labelledby="chart_editor_modal" aria-hidden="true">
<div class="modal-dialog modal-fullscreen modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">차트 정보</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="editor_container">
<input type="hidden" name="type" value="" />
<div class="left-panel default-info">
${LayoutUtil.createForm(this.chartEditorModel)}
</div>
<div class="editor-right-panel">
<div class="series-config d-flex flex-column">
<h6>Data Source</h6>
<div class="d-flex flex-row">
<div class="form-floating me-2" style="flex: 1">
<select class="form-select" name="seriesConfig[0].queryType">
<option value="elasticsearch">Elasticsearch</option>
</select>
<label for="queryType">Query Type</label>
</div>
<div class="form-floating me-2" style="flex: 1">
<input class="form-control" type="text" name="seriesConfig[0].index" value="" />
<label for="index">Elasticsearch Index</label>
</div>
</div>
<div class="d-flex flex-row mt-2" style="flex: 1">
<div style="flex: 1">
<div class="form-floating me-2" style="height: 100%">
<textarea class="form-control" type="text" name="seriesConfig[0].query" id="seriesConfigQuery" placeholder="Code goes here..." style="height: 400px;">
</textarea>
</div>
</div>
<div style="flex: 1">
<div class="form-floating me-2" style="height: 100%">
<textarea class="form-control" type="text" name="seriesConfig[0].queryResult" style="height: 400px;" readonly>
</textarea>
<label for="query">요청 결과</label>
</div>
</div>
</div>
<div class="d-flex flex-row mt-2" style="flex: 1">
<div style="flex: 1">
<div class="form-floating me-2" style="height: 100%">
<textarea class="form-control" type="text" name="seriesConfig[0].mapperY" style="height: 400px;">
</textarea>
<label for="mapper">데이터 Mapper</label>
</div>
</div>
<div style="flex: 1">
<div class="form-floating me-2" style="height: 100%">
<textarea class="form-control" type="text" name="seriesConfig[0].mapperX" style="height: 400px;">
</textarea>
<label for="mapper">X축 Mapper</label>
</div>
</div>
</div>
<div class="d-flex flex-row" style="height: 400px">
<div style="flex: 1">
<div class="form-floating me-2" style="height: 100%">
<textarea class="form-control" type="text" id="echartOption" style="width: 100%; height: 400px;" readonly>
</textarea>
<label for="mapper">생성된 Option</label>
</div>
</div>
<div style="flex: 1">
<div class="chart-preview form-control" id="chart-preview" style="height: 400px;">
chart_preview
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary btn-sm" data-bs-dismiss="modal">닫기</button>
<button type="button" class="btn btn-primary btn-sm add-chart">추가</button>
<button type="button" class="btn btn-primary btn-sm save-chart">저장</button>
</div>
</div>
</div>
</div>`;
$(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();
});
}
}
-344
View File
@@ -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 `
<div class="input-group mb-3">
<div class="form-floating">
<input id="${id}Input" type="text" class="form-control" data-td-target="#${id}"/>
<label for="${id}Input">${label}</label>
</div>
<span class="input-group-text" data-td-target="#${id}" data-td-toggle="datetimepicker">
<span class="fa-solid fa-calendar"></span>
</span>
</div>
`;
}
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 = `<script>
function get(obj, path, defaultValue = undefined) {
const keys = Array.isArray(path) ? path : path.replace(/\\[(\\d+)\\]/g, '.$1').split('.');
let result = obj;
for (let key of keys) {
result = result?.[key];
if (result === undefined) {
return defaultValue;
}
}
return result;
}
function 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}\`;
}
function 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\`;
}
function formatDateTime(isoString) {
return formatDate(isoString) + 'T' + formatTime(isoString);
}
function startOfDay(isoString) {
return formatDate(isoString) + 'T00:00:00';
}
window.result = window.parent.result;
try {
const executeFunction = ${script};
const data = executeFunction(window.result);
window.parent.preRequestComplete(data, null);
} catch (error) {
window.parent.preRequestComplete(null, error);
}
</script>`;
});
} 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
}
}
}
@@ -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 = `
<div class="modal fade" id="container_editor_modal" tabindex="-1" aria-labelledby="container_editor_modal" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">컨테이너 정보</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="containerOption">
${LayoutUtil.createForm(this.containerEditorModel)}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary btn-sm" data-bs-dismiss="modal">닫기</button>
<button type="button" class="btn btn-primary btn-sm add-container">추가</button>
<button type="button" class="btn btn-primary btn-sm save-container">저장</button>
</div>
</div>
`;
$(this.rootContainer).parent().append(containerEditorPopup);
}
initDashboardEditor() {
const containerEditorPopup = `
<div class="modal fade" id="dashboard_editor_modal" tabindex="-1" aria-labelledby="dashboard_editor_modal" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">대시보드 정보</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="containerOption">
${LayoutUtil.createForm(this.dashboardEditorModel)}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary btn-sm" data-bs-dismiss="modal">닫기</button>
<button type="button" class="btn btn-primary btn-sm save-dashboard">적용</button>
</div>
</div>
`;
$(this.rootContainer).parent().append(containerEditorPopup);
}
resetLayout() {
console.log('resetLayout');
$(this.rootContainer).empty();
this.renderRoot(this.layoutData, this.rootContainer);
this.dashboard.initCharts();
this.dashboard.layoutData = this.layoutData;
this.dashboard.drawCharts();
this.editContainerIndex = -1;
this.bindEvents();
}
addNewChart(containerIndex) {
this.editContainerIndex = containerIndex;
let tempChart = {
'contentId': '',
'type': 'chart',
'styles': {
width: '100%', height: '100%'
},
'config': {
'tooltip': {
'trigger': 'axis',
'show': true
},
'legend': {
'show': true
},
'grid': {
'show': true,
'top': 0,
'bottom': 0,
'left': 0,
'right': 0,
'containLabel': true
},
'title': {},
'xAxis': {
'show': true,
'type': 'category'
},
'yAxis': [
{
'show': true,
'name': '',
'type': 'value',
'position': 'left'
}],
'toolbox': {
'show': true
}
},
'seriesConfig': [
{
'type': 'line',
'index': '',
'queryType': 'elasticsearch',
'query': '',
'mapperX': `
function(result) {
let data = [];
return data;
}
`,
'mapperY': `
function(result) {
let data = [{
type : 'line',
data: []
}];
return data;
}
`,
'queryResult': ''
}
]
};
this.chartEditor.showChartEditor(true, tempChart, containerIndex);
}
editChart(editContainerIndex, editColumnIndex) {
console.log('edit-chart');
let tempChart = this.layoutData.children[editContainerIndex].children[editColumnIndex];
this.chartEditor.showChartEditor(false, JSON.parse(JSON.stringify(tempChart, LayoutUtil.echartReplacer)), editContainerIndex, editColumnIndex);
}
renderRoot(layoutData, parent) {
let classList = 'dashboard-root mt-2' + (!this.viewMode ? ' edit' : '');
let children = '';
let style = '';
if (layoutData.styles) {
style = Object.keys(layoutData.styles).map(key => `${key}: ${layoutData.styles[key]};`).join(' ');
}
console.log({style});
if (layoutData.children) {
layoutData.children.forEach((child) => {
if (child.type === 'container') {
children += this.renderContainer(child);
} else if (child.type === 'chart') {
children += this.renderChart(child);
}
});
}
let layoutItem = `<div class="${classList}" style="${style}"> ${children}</div>`;
$(parent).append(layoutItem);
// if (!this.viewMode) {
// this.traverse(this.layoutData);
// }
};
renderChart(layoutData) {
let style = '';
if (layoutData.styles) {
style = Object.keys(layoutData.styles).map(key => `${key}: ${layoutData.styles[key]};`).join(' ');
}
let editButton = this.viewMode ? '' : `<div class="chart-menu-icon" data-content-id="chart_${layoutData.contentId}">
<div class="dropdown">
<span class="" data-bs-toggle="dropdown" aria-expanded="false">⋮</span>
<ul class="dropdown-menu">
<li><span class="dropdown-item edit-chart">차트 편집</span></li>
<li><span class="dropdown-item remove-chart">차트 제거</span></li>
</ul>
</div>
</div>`;
return `
<div class="dashboard-chart" data-content-id="chart_${layoutData.contentId}" style="${style}">
${editButton}
<div id="chart_${layoutData.contentId}" style="width: 100%; height: 100%"></div>
</div>
`;
}
renderContainer(layoutData) {
console.log('renderContainer');
let children = '';
if (layoutData.children) {
layoutData.children.forEach(child => {
if (child.type === 'container') {
children += this.renderContainer(child);
} else if (child.type === 'chart') {
children += this.renderChart(child);
}
});
}
let width = layoutData.styles.width || '100%';
let height = layoutData.styles.height || '100%';
let editButton = this.viewMode ? '' : `
<div class="container-menu-icon">
<div class="dropdown">
<span><i class="fa-solid fa-arrows-up-down-left-right"></i></span>
<span class="" data-bs-toggle="dropdown" aria-expanded="false">⋮</span>
<ul class="dropdown-menu">
<li><span class="dropdown-item edit-container">컨테이너 편집</span></li>
<li><span class="dropdown-item remove-container">컨테이너 제거</span></li>
<li><span class="dropdown-item add-new-chart">차트 추가</span></li>
</ul>
</div>
</div>`;
let style = '';
if (layoutData.styles) {
style = Object.keys(layoutData.styles).map(key =>{
if (key === 'width' || key === 'height') return `${key}: 100%;`;
return `${key}: ${layoutData.styles[key]};`;
} ).join(' ');
}
return `<div class="dashboard-container" style="width: ${width}; height: ${height};">
<div class="handle">
${editButton}
</div>
<div class="dashboard-container-children" style="${style}">
${children}
</div>
</div>`;
}
addNewContainer(event) {
this.tempContainer = {
type: 'container',
styles: {
display: 'flex',
'flex-direction': 'row',
width: '100%',
height: '300px'
},
children: []
};
this.showContainerEditor(true);
}
editContainer(editContainerIndex) {
console.log('edit-container: ' + editContainerIndex);
this.tempContainer = JSON.parse(JSON.stringify(this.layoutData.children[editContainerIndex], LayoutUtil.echartReplacer));
this.showContainerEditor(false);
this.editContainerIndex = editContainerIndex;
}
removeContainer(containerIndex) {
console.log('edit-container: ' + containerIndex);
this.layoutData.children.splice(containerIndex, 1);
this.resetLayout();
}
addContainer(event) {
$('#container_editor_modal').find('input, select, textarea').each((index, element) => {
const fieldName = $(element).attr('name');
const newValue = LayoutUtil.getFieldValue(element);
if (fieldName !== '') {
_.set(this.tempContainer, fieldName, newValue);
}
});
this.layoutData.children.push(JSON.parse(JSON.stringify(this.tempContainer)));
this.tempContainer = null;
this.resetLayout();
$('#container_editor_modal').modal('hide');
}
saveContainer(event) {
console.log('save-container: ' + this.editContainerIndex);
$('#container_editor_modal').find('input, select, textarea').each((index, element) => {
const fieldName = $(element).attr('name');
const newValue = LayoutUtil.getFieldValue(element);
if (fieldName !== '') {
_.set(this.tempContainer, fieldName, newValue);
}
});
this.layoutData.children[this.editContainerIndex] = JSON.parse(JSON.stringify(this.tempContainer));
this.tempContainer = null;
this.resetLayout();
$('#container_editor_modal').modal('hide');
}
removeChart(containerIndex, columnIndex) {
this.layoutData.children[containerIndex].children.splice(columnIndex, 1);
this.resetLayout();
}
onRowsSorted(ui) {
let originalContainerIndex = ui.item.data('original_row_index');
this.updateLayoutDataRows(originalContainerIndex, ui);
}
onColumnsSorted(ui) {
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
let originalContainerIndex = ui.item.data('original_row_index');
if (originalContainerIndex) {
if (originalContainerIndex === containerIndex) { // Check if the column moved from another row
this.updateLayoutDataWithinRow(ui.item);
} else { // The column was sorted within the same row
this.updateLayoutDataColumns(ui.item);
}
this.resetLayout();
}
}
updateLayoutDataRows(originalContainerIndex, ui) {
let targetIndex = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
if (targetIndex !== originalContainerIndex) {
this.layoutData.children.splice(targetIndex, 0, this.layoutData.children.splice(originalContainerIndex, 1)[0]);
}
console.log(this.layoutData);
}
updateLayoutDataWithinRow(movedItem) {
let oldContainerIndex = movedItem.data('original_row_index');
let oldIndex = movedItem.data('original_column_index');
if (oldContainerIndex !== -1 && oldIndex !== -1) {
let $row = movedItem.closest('.dashboard-container');
let newIndex = $row.children('.dashboard-chart').index(movedItem);
this.layoutData.children[oldContainerIndex].children.splice(newIndex, 0, this.layoutData.children[oldContainerIndex].children.splice(oldIndex, 1)[0]);
}
}
save() {
const jsonString = JSON.stringify(this.layoutData, LayoutUtil.echartReplacer);
return jsonString;
}
updateLayoutDataColumns(movedItem) {
// 새로운 행과 이전 행의 jQuery 요소를 가져옵니다
let $newRow = movedItem.closest('.dashboard-container');
// 이전 행의 레이아웃 데이터에서 이동된 행과 열의 인덱스를 찾습니다
let oldContainerIndex = movedItem.data('original_row_index');
let movedColumnIndex = movedItem.data('original_column_index');
if (oldContainerIndex !== -1 && movedColumnIndex !== undefined && movedColumnIndex !== -1) {
movedItem.data('original_row_index', -1);
movedItem.data('original_column_index', -1);
// 새 행의 인덱스를 찾습니다
let newContainerIndex = $('.dashboard-root').children('.dashboard-container').index($newRow);
// 이전 행에서 열 데이터를 추출합니다
let movedColumnData = this.layoutData.children[oldContainerIndex].children.splice(movedColumnIndex, 1)[0];
// 아이템이 드롭된 새 열 인덱스를 찾습니다
let newColumnIndex = movedItem.closest('.dashboard-container-children').children('.dashboard-chart').index(movedItem);
// 이동된 열 데이터를 새 행의 열에 새 인덱스에 삽입합니다
// 아이템이 마지막 위치로 이동된 경우 인덱스는 -1이 될 것이므로 이 경우를 처리합니다
if (newColumnIndex === -1) {
this.layoutData.children[newContainerIndex].children.push(movedColumnData);
} else {
this.layoutData.children[newContainerIndex].children.splice(newColumnIndex, 0, movedColumnData);
}
} else {
console.log('called twice on update');
}
}
showContainerEditor(isNew) {
if (isNew) {
$('.add-container').show();
$('.save-container').hide();
} else {
$('.add-container').hide();
$('.save-container').show();
}
let flatModel = LayoutUtil.flattenObject(JSON.parse(JSON.stringify(this.tempContainer)));
$('#container_editor_modal').find('input, select, textarea').each((index, element) => {
if ($(element).attr('type') === 'checkbox') {
$(element).prop('checked', false);
}
$(element).val('');
});
_.forEach(flatModel, (value, key) => {
if ($('#container_editor_modal').find(`input[name="${key}"]`).attr('type') === 'checkbox') {
$('#container_editor_modal').find(`input[name="${key}"]`).prop('checked', value);
} else {
$('#container_editor_modal').find(`input[name="${key}"], select[name="${key}"], textarea[name="${key}"]`).val(value);
}
});
$('#container_editor_modal').modal('show');
}
resize() {
console.log('resize');
this.dashboard.resizeAllCharts();
}
editDashboard(event) {
this.showDashboardEditor();
}
showDashboardEditor() {
let flatModel = LayoutUtil.flattenObject(JSON.parse(JSON.stringify(this.layoutData, LayoutUtil.echartReplacer)));
$('#dashboard_editor_modal').find('input, select, textarea').each((index, element) => {
if ($(element).attr('type') === 'checkbox') {
$(element).prop('checked', false);
}
$(element).val('');
});
_.forEach(flatModel, (value, key) => {
if ($('#dashboard_editor_modal').find(`input[name="${key}"]`).attr('type') === 'checkbox') {
$('#dashboard_editor_modal').find(`input[name="${key}"]`).prop('checked', value);
} else {
$('#dashboard_editor_modal').find(`input[name="${key}"], select[name="${key}"], textarea[name="${key}"]`).val(value);
}
});
$('#dashboard_editor_modal').modal('show');
}
saveDashboard(event) {
$('#dashboard_editor_modal').find('input, select, textarea').each((index, element) => {
const fieldName = $(element).attr('name');
const newValue = LayoutUtil.getFieldValue(element);
if (fieldName !== '') {
_.set(this.layoutData, fieldName, newValue);
}
});
console.log(this.layoutData);
this.resetLayout();
$('#dashboard_editor_modal').modal('hide');
}
bindEvents() {
$('.add-new-container').off('click').on('click', (event) => {
this.addNewContainer(event);
});
$('.edit-dashboard').off('click').on('click', (event) => {
this.editDashboard(event);
});
$('.save-dashboard').off('click').on('click', (event) => {
this.saveDashboard(event);
});
$('.remove-container').off('click').on('click', (event) => {
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
this.removeContainer(containerIndex);
});
$('.add-container').off('click').on('click', (event) => {
this.addContainer(event);
});
$('.save-container').off('click').on('click', (event) => {
this.saveContainer(event);
});
$('.edit-container').off('click').on('click', (event) => {
let editContainerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
this.editContainer(editContainerIndex);
});
$('.edit-chart').off('click').on('click', (event) => {
let editContainerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
let column = event.target.closest('.dashboard-chart');
let editColumnIndex = $(event.target.closest('.dashboard-container-children')).children('.dashboard-chart').index(column);
this.editChart(editContainerIndex, editColumnIndex);
});
$('.add-new-chart').off('click').on('click', (event) => {
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
this.addNewChart(containerIndex);
});
$('.remove-chart').off('click').on('click', (event) => {
let column = event.target.closest('.dashboard-chart');
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
let columnIndex = $(event.target.closest('.dashboard-container-children')).children('.dashboard-chart').index(column);
this.removeChart(containerIndex, columnIndex);
});
if ($('.dashboard-root').hasClass('ui-sortable')) {
$('.dashboard-root').sortable('destroy');
}
$('.dashboard-root').sortable({
handle: '.handle',
items: '.dashboard-container',
placeholder: 'ui-state-highlight',
start: (event, ui) => {
let originalRowIdx = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
ui.item.data('original_row_index', originalRowIdx);
}, update: (event, ui) => {
this.onRowsSorted(ui);
}
});
if ($('.dashboard-container').hasClass('ui-sortable')) {
$('.dashboard-container').sortable('destroy');
}
$('.dashboard-container-children').sortable({
// handle: ".handle",
placeholder: 'ui-state-highlight',
items: '.dashboard-chart',
connectWith: '.dashboard-container-children',
update: (event, ui) => {
this.onColumnsSorted(ui);
},
start: (event, ui) => {
let originalRowIdx = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
let originalColumnIdx = ui.item.closest('.dashboard-container-children').children('.dashboard-chart').index(ui.item);
ui.item.data('original_row_index', originalRowIdx);
ui.item.data('original_column_index', originalColumnIdx);
}
}).disableSelection();
}
}
+3 -2
View File
@@ -8,7 +8,7 @@
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
* - 페이징: #apiPagination (PAGE_SIZE 건/페이지) — 카테고리/검색은 재조회 없이 클라이언트에서 처리
* - 페이징: #apiPagination (window.API_SELECTOR_PAGE_SIZE 건/페이지, 기본 15) — 카테고리/검색은 클라이언트에서 처리
*
* design(figma s2) 인라인 스크립트 대비 패치 4건:
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
@@ -22,7 +22,8 @@ document.addEventListener('DOMContentLoaded', function() {
return; // 모듈 미사용 페이지
}
const PAGE_SIZE = 12;
const configuredPageSize = Number(globalThis.API_SELECTOR_PAGE_SIZE);
const PAGE_SIZE = Number.isInteger(configuredPageSize) && configuredPageSize > 0 ? configuredPageSize : 15;
// DOM Elements
const searchInput = document.getElementById('apiSearch');
@@ -160,10 +160,14 @@
const cards = visible.map(function (incident) {
const timeline = (incident.recentTimeline || []).map(function (entry) {
// 지난 이슈 이력과 같은 기준: 진행 상태(labelKo)를 상태 색으로 구분해 보여준다.
const state = entry.stateAfter || 'NONE';
const label = entry.labelKo || '진행 상황';
const who = entry.authorType === 'SYSTEM' ? 'SYSTEM' : '운영자';
const whoClass = entry.authorType === 'SYSTEM' ? ' is-system' : '';
return '<div class="as-tl-row">'
+ '<span class="as-tl-time">' + escapeHtml(formatTime(entry.eventAt)) + '</span>'
+ '<span class="as-tl-state state-' + escapeHtml(state) + '">' + escapeHtml(label) + '</span>'
+ '<span class="as-tl-who' + whoClass + '">' + escapeHtml(who) + '</span>'
+ '<span>' + escapeHtml(entry.body) + '</span>'
+ '</div>';
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
Copyright (c) JS Foundation and other contributors
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,605 @@
/**
* 비밀번호 전송암호화 (RSA-OAEP + AES-GCM 하이브리드).
*
* 브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막는다.
* 서버 공개키로 AES-256-GCM 키를 감싸고, 비밀번호는 그 AES 키로 암호화해 아래 봉투로 보낸다.
*
* ENC1.<keyId>.<b64url(RSA-OAEP(AES키))>.<b64url(iv 12B)>.<b64url(암호문+태그)>
*
* 서버(PasswordDecryptFilter)가 파라미터 값을 평문으로 되돌리므로 컨트롤러는 아무것도 몰라도 된다.
*
* 암호화 엔진은 두 가지이며 봉투 형식은 동일하다.
* - subtle : secure context(HTTPS·localhost)에서 브라우저 Web Crypto 사용. 추가 다운로드 없음.
* - forge : 원격 오리진 HTTP 처럼 crypto.subtle 이 없는 환경에서 순수 JS 구현 사용.
* lib/forge-crypto.min.js 를 그때만 동적으로 내려받는다
* (window.__PASSWORD_CRYPTO__.softwareFallback 로 끌 수 있다).
*
* 폴백: 설정이 꺼져 있거나, 두 엔진 모두 쓸 수 없거나, 키 조회·암호화 중 오류가 나면 아무 일도 하지 않고
* 평문 그대로 전송한다. 즉 이 모듈은 절대 화면 흐름을 막지 않는다. 서버 정책이 ENFORCE 면 그 평문이
* 거부되어 인증 실패로 이어진다.
*
* 주의: 이 조치는 XSS 방어도 MITM 방어도 아니다. 스크립트가 주입되면 입력창에서 직접 값을 가져갈 수 있고,
* HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다. 그래서 HTTP 접속에는 경고 팝업을 띄운다.
*/
(function (global) {
'use strict';
var cfg = global.__PASSWORD_CRYPTO__ || {};
var PREFIX = 'ENC1.';
/** 봉투를 만들 수 있는 환경에서 전송로 경고를 세션당 한 번만 띄우기 위한 sessionStorage 키 */
var WARN_KEY = 'portal.passwordCrypto.insecureWarned';
// ---------- 엔진 판정 ----------
/**
* Web Crypto 사용 가능 여부. 브라우저는 secure context 가 아니면 crypto.subtle 자체를 노출하지 않는다.
* 반면 crypto.getRandomValues 는 비 secure context 에서도 쓸 수 있어 forge 경로의 난수원으로 쓴다.
*/
function hasSubtle() {
return !!(global.isSecureContext && global.crypto && global.crypto.subtle);
}
function hasForge() {
return !!(cfg.softwareFallback && cfg.forgeUrl && global.document);
}
/** 설정·브라우저 조건상 봉투를 만들 수 있는가. 실제 forge 스크립트 로드는 사용 시점에 한다. */
function available() {
return !!(cfg.enabled
&& cfg.keyUrl
&& global.crypto
&& global.crypto.getRandomValues
&& global.fetch
&& global.Promise
&& (hasSubtle() || hasForge()));
}
// ---------- 인코딩 유틸 ----------
function bytesToBinary(bytes) {
var binary = '';
for (var i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return binary;
}
function binaryToBase64Url(binary) {
return global.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function toBase64Url(buffer) {
return binaryToBase64Url(bytesToBinary(new Uint8Array(buffer)));
}
function base64ToBytes(base64) {
var binary = global.atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/** forge 는 바이트를 binary string 으로 다룬다. 난수는 항상 브라우저 CSPRNG 에서 받는다. */
function randomBinary(length) {
return bytesToBinary(global.crypto.getRandomValues(new Uint8Array(length)));
}
// ---------- 엔진 구현 ----------
/**
* 엔진 계약:
* importKey(base64Spki) -> Promise<publicKey>
* seal(publicKey, keyId, names, values) -> Promise<{이름: 봉투}>
* 두 엔진의 봉투 형식·알고리즘 파라미터는 동일해야 한다. 서버(PasswordEnvelopeCodec)는
* RSA-OAEP 의 MGF1 해시까지 SHA-256 으로 고정돼 있으므로 forge 쪽도 mgf1 을 명시해야 한다.
*/
var subtleEngine = {
name: 'subtle',
importKey: function (base64Spki) {
return global.crypto.subtle.importKey(
'spki',
base64ToBytes(base64Spki),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt']
);
},
seal: function (publicKey, keyId, names, values) {
var subtle = global.crypto.subtle;
var context = {};
return subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt'])
.then(function (aesKey) {
context.aesKey = aesKey;
return subtle.exportKey('raw', aesKey);
})
.then(function (rawAesKey) {
return subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, rawAesKey);
})
.then(function (wrapped) {
var wrappedKey = toBase64Url(wrapped);
return global.Promise.all(names.map(function (name) {
var iv = global.crypto.getRandomValues(new Uint8Array(12));
return subtle.encrypt(
{ name: 'AES-GCM', iv: iv, tagLength: 128 },
context.aesKey,
new TextEncoder().encode(values[name])
).then(function (cipherText) {
return {
name: name,
envelope: PREFIX + keyId + '.' + wrappedKey
+ '.' + toBase64Url(iv) + '.' + toBase64Url(cipherText)
};
});
}));
})
.then(collectEnvelopes);
}
};
var forgeEngine = {
name: 'forge',
importKey: function (base64Spki) {
return loadForge().then(function (forge) {
var der = forge.util.createBuffer(global.atob(base64Spki));
return forge.pki.publicKeyFromAsn1(forge.asn1.fromDer(der));
});
},
seal: function (publicKey, keyId, names, values) {
return loadForge().then(function (forge) {
var aesKey = randomBinary(32);
// forge 의 OAEP 시드도 브라우저 CSPRNG 로 준다. forge 내장 PRNG 를 쓰지 않기 위함이다.
var wrappedKey = binaryToBase64Url(publicKey.encrypt(aesKey, 'RSA-OAEP', {
md: forge.md.sha256.create(),
mgf1: { md: forge.md.sha256.create() },
seed: randomBinary(32)
}));
return names.map(function (name) {
var iv = randomBinary(12);
var cipher = forge.cipher.createCipher('AES-GCM', aesKey);
cipher.start({ iv: iv, tagLength: 128 });
cipher.update(forge.util.createBuffer(forge.util.encodeUtf8(values[name])));
cipher.finish();
// 서버는 Java 관례대로 "암호문 + 태그" 가 이어 붙은 형태를 기대한다.
var payload = cipher.output.getBytes() + cipher.mode.tag.getBytes();
return {
name: name,
envelope: PREFIX + keyId + '.' + wrappedKey
+ '.' + binaryToBase64Url(iv) + '.' + binaryToBase64Url(payload)
};
});
}).then(collectEnvelopes);
}
};
function collectEnvelopes(results) {
var out = {};
results.forEach(function (result) {
out[result.name] = result.envelope;
});
return out;
}
/** 현재 환경에서 쓸 엔진. 페이지 수명 동안 바뀌지 않는다. */
function engine() {
if (hasSubtle()) {
return subtleEngine;
}
return hasForge() ? forgeEngine : null;
}
// ---------- forge 동적 로드 ----------
var forgeLoading = null;
/**
* forge 번들은 crypto.subtle 이 없을 때만 내려받는다. HTTPS 운영 환경에서는 전송 바이트가 0 이다.
* 한 번 시작한 로드는 재사용하고, 실패하면 호출자가 평문 폴백으로 처리한다.
*/
function loadForge() {
if (global.forge && global.forge.pki && global.forge.cipher) {
return global.Promise.resolve(global.forge);
}
if (forgeLoading) {
return forgeLoading;
}
forgeLoading = new global.Promise(function (resolve, reject) {
var script = global.document.createElement('script');
script.src = cfg.forgeUrl;
script.async = true;
script.onload = function () {
if (global.forge && global.forge.pki && global.forge.cipher) {
resolve(global.forge);
} else {
reject(new Error('forge 번들이 전역을 노출하지 않음'));
}
};
script.onerror = function () {
reject(new Error('forge 번들 로드 실패: ' + cfg.forgeUrl));
};
global.document.head.appendChild(script);
});
return forgeLoading;
}
// ---------- 키 조회 ----------
/** 재사용 가능한 스코프(SESSION/SERVER)에서만 채워진다. { keyId, publicKey, expiresAt } */
var cachedKey = null;
/**
* 서버에서 공개키를 받아 현재 엔진 형식으로 import 한다.
*
* key-scope 가 REQUEST 면 발급된 키가 1회용이라 매번 새로 받는다. SESSION/SERVER 면 만료 전까지
* 캐시해 재사용한다 — 비밀번호 검증용 ajax(입력 중 호출)가 호출마다 서버에서 RSA 키쌍을
* 생성하게 만들지 않기 위함이다.
*/
function loadKey(currentEngine) {
if (cachedKey && cachedKey.expiresAt > Date.now()) {
return global.Promise.resolve(cachedKey);
}
return global.fetch(cfg.keyUrl, {
method: 'GET',
credentials: 'same-origin',
cache: 'no-store',
headers: { 'Accept': 'application/json' }
}).then(function (response) {
if (!response.ok) {
throw new Error('공개키 조회 실패: HTTP ' + response.status);
}
return response.json();
}).then(function (data) {
if (!data || !data.enabled || !data.keyId || !data.publicKey) {
throw new Error('전송암호화 비활성 상태의 응답');
}
return currentEngine.importKey(data.publicKey).then(function (publicKey) {
var key = { keyId: data.keyId, publicKey: publicKey };
if (data.keyScope && data.keyScope !== 'REQUEST') {
// 만료 30초 전에는 버려서 경계에서 실패하지 않게 한다.
var ttlMs = Math.max(0, (data.expiresIn || 0) - 30) * 1000;
if (ttlMs > 0) {
key.expiresAt = Date.now() + ttlMs;
cachedKey = key;
}
}
return key;
});
});
}
// ---------- 암호화 ----------
/**
* 값 여러 개를 한 번에 암호화한다. AES 키와 RSA 랩은 1회만 하고 IV 만 값마다 새로 만든다.
* @param {Object} values 이름 → 평문
* @returns {Promise<Object>} 이름 → 봉투 문자열
*/
function encryptValues(values) {
var names = Object.keys(values || {}).filter(function (name) {
var v = values[name];
return typeof v === 'string' && v.length > 0 && v.indexOf(PREFIX) !== 0;
});
var currentEngine = available() ? engine() : null;
if (!currentEngine || names.length === 0) {
return global.Promise.resolve(values || {});
}
return loadKey(currentEngine).then(function (key) {
return currentEngine.seal(key.publicKey, key.keyId, names, values);
}).then(function (envelopes) {
var out = {};
Object.keys(values).forEach(function (name) { out[name] = values[name]; });
Object.keys(envelopes).forEach(function (name) { out[name] = envelopes[name]; });
return out;
}).catch(function (error) {
// 암호화 실패는 화면을 막지 않는다. 서버 정책이 ENFORCE 가 아니면 평문으로 처리된다.
if (global.console && global.console.warn) {
global.console.warn('[password-crypto] 평문으로 폴백:', error && error.message);
}
return values;
});
}
/** 원본 입력에서 name 을 잠시 떼어둔 동안 그 이름을 보관하는 표시용 속성 */
var NAME_HOLDER = 'data-password-crypto-name';
/**
* 입력 요소에서 실제 <input> 하나를 고른다.
* 같은 name 이 둘 이상이면 form.elements[name] 이 RadioNodeList 를 돌려주므로,
* 우리가 붙인 hidden 이 아닌 실제 입력창을 골라야 한다.
*/
function resolveInput(el) {
if (!el) {
return null;
}
if (el.tagName) {
return el;
}
for (var i = 0; i < el.length; i++) {
var candidate = el[i];
if (candidate && candidate.tagName && candidate.type !== 'hidden') {
return candidate;
}
}
return null;
}
/**
* 원본 입력의 value 는 그대로 두고, name 만 같은 이름의 hidden 으로 옮겨 봉투를 싣는다.
* @returns {Function} 원상복구 함수
*/
function swapToEnvelopeField(form, input, name, envelope) {
var hidden = (form.ownerDocument || global.document).createElement('input');
hidden.type = 'hidden';
hidden.name = name;
hidden.value = envelope;
hidden.setAttribute(NAME_HOLDER, name);
input.setAttribute(NAME_HOLDER, name);
input.removeAttribute('name');
form.appendChild(hidden);
return function restore() {
if (hidden.parentNode) {
hidden.parentNode.removeChild(hidden);
}
if (input.getAttribute(NAME_HOLDER) === name) {
input.setAttribute('name', name);
input.removeAttribute(NAME_HOLDER);
}
};
}
/**
* 제출 직후 원상복구한다. 폼 데이터 직렬화는 form.submit()/requestSubmit() 호출 시점에
* 동기로 끝나므로, 다음 매크로태스크에서 되돌려도 봉투는 이미 전송된 뒤다.
* 뒤로가기(bfcache) 복원 시에도 name 이 떨어진 채 남지 않도록 pageshow 에서 한 번 더 처리한다.
*/
function scheduleRestore(restores) {
if (restores.length === 0) {
return;
}
var done = false;
var run = function () {
if (done) {
return;
}
done = true;
restores.forEach(function (restore) { restore(); });
global.removeEventListener('pageshow', run);
};
global.addEventListener('pageshow', run);
global.setTimeout(run, 0);
}
/**
* 폼의 지정 필드를 봉투로 전송한다. 실패해도 reject 하지 않고 평문을 남긴다.
*
* 입력창(<input type="password">)의 value 는 절대 건드리지 않는다. 브라우저 비밀번호 관리자는
* 제출 시점의 입력값을 그대로 저장하므로 봉투로 덮어쓰면 봉투 문자열이 비밀번호로 저장되고,
* 다음 자동완성 로그인이 깨진다. 대신 원본 입력의 name 을 잠시 떼고 같은 name 의 hidden 에
* 봉투를 실어 보낸 뒤 제출 직후 되돌린다.
*
* @param {HTMLFormElement} form
* @param {string[]} fieldNames
* @returns {Promise<void>}
*/
function encryptForm(form, fieldNames) {
if (!form || !fieldNames || fieldNames.length === 0 || !available()) {
return global.Promise.resolve();
}
var targets = {};
var elements = {};
fieldNames.forEach(function (name) {
var el = resolveInput(form.elements ? form.elements[name] : null);
if (el && typeof el.value === 'string' && el.value.length > 0) {
elements[name] = el;
targets[name] = el.value;
}
});
if (Object.keys(targets).length === 0) {
return global.Promise.resolve();
}
return encryptValues(targets).then(function (encrypted) {
var restores = [];
Object.keys(elements).forEach(function (name) {
// 폴백으로 평문이 그대로 돌아온 경우엔 아무것도 바꾸지 않는다.
if (encrypted[name] && encrypted[name] !== targets[name]) {
restores.push(swapToEnvelopeField(form, elements[name], name, encrypted[name]));
}
});
scheduleRestore(restores);
});
}
// ---------- 전송로 경고 ----------
/**
* 팝업을 띄운다. custom-popups.js 는 `const customPopups`(전역 렉시컬)로 노출돼
* window 프로퍼티가 아니므로 식별자로 직접 확인한다. 팝업 프래그먼트가 없는 화면에서는
* 브라우저 기본 alert 로 떨어진다.
*/
function showDialog(message) {
try {
if (typeof customPopups !== 'undefined'
&& customPopups && typeof customPopups.showAlert === 'function'
&& global.document.getElementById('customAlert')) {
customPopups.showAlert(message);
return;
}
} catch (e) {
// customPopups 미정의 등 — 아래 기본 alert 로 떨어진다.
}
global.alert(message.replace(/<br\s*\/?>/gi, '\n'));
}
/** 알림 팝업이 지금 떠 있는가. custom-popups.js 는 #customAlert 를 show()/hide() 로 토글한다. */
function isAlertOpen() {
var el = global.document.getElementById('customAlert');
return !!(el && el.style.display !== 'none');
}
/**
* 이미 떠 있는 알림이 닫힌 뒤에 띄운다.
*
* 알림 팝업은 페이지 전체가 #customAlert 하나를 돌려쓰고 showAlert() 는 그 안의 메시지를 덮어쓴다.
* 우리 경고는 load 시점이라 화면 자신의 메시지(DOMContentLoaded 에서 뜨는 로그인 실패 안내 등)보다
* 늦게 실행되므로, 그냥 부르면 그 메시지를 지워버린다. 먼저 뜬 쪽을 존중하고 뒤에 선다.
*
* hideAlert() 가 300ms 애니메이션 뒤에 display 를 내리므로 폴링으로 확인한다.
* 사용자가 계속 닫지 않으면 1분 뒤 포기한다(경고를 못 봐도 화면을 방해하지는 않는다).
*/
function showDialogQueued(message) {
if (!isAlertOpen()) {
showDialog(message);
return;
}
var waited = 0;
var timer = global.setInterval(function () {
waited += 200;
if (!isAlertOpen()) {
global.clearInterval(timer);
showDialog(message);
} else if (waited >= 60000) {
global.clearInterval(timer);
}
}, 200);
}
/**
* HTTPS 가 아닌 연결에서 비밀번호를 입력하려는 화면에 경고를 띄운다.
*
* 정책이 NONE 이면 아무것도 하지 않는다. 문구와 노출 빈도는 이 브라우저가 봉투를 만들 수 있는지로 갈린다.
*
* <ul>
* <li>봉투를 만들 수 있음 - 정보성 경고다. 값은 암호화되어 나가고, 남는 위험은 중간자가 이 스크립트
* 자체를 바꿔치기하는 경우뿐이다. 매번 띄우면 방해만 되므로 <b>세션당 1회</b>.</li>
* <li>봉투를 만들 수 없음 - 비밀번호가 평문으로 나가거나(permissive), 서버가 거부해 로그인 자체가
* 안 된다(enforce). 사용자가 놓치면 안 되는 상태이므로 <b>화면을 열 때마다</b>.</li>
* </ul>
*/
function warnInsecureTransport() {
if (!cfg.enabled || !cfg.policy || cfg.policy === 'NONE') {
return;
}
if (!global.location || global.location.protocol === 'https:') {
return;
}
if (!global.document.querySelector('input[type="password"]')) {
return;
}
var encryptable = available();
if (encryptable && !markWarnedOnce()) {
return;
}
var message;
if (encryptable) {
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>로 접속했습니다.<br>'
+ '비밀번호는 전송 전에 암호화되지만, 중간자 공격까지 막지는 못합니다.<br>'
+ '운영 환경에서는 HTTPS 로 접속하세요.';
} else if (cfg.policy === 'ENFORCE') {
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>이고, 이 브라우저에서는 '
+ '비밀번호 암호화를 사용할 수 없습니다.<br>'
+ '서버가 암호화되지 않은 비밀번호를 거부하므로 로그인할 수 없습니다.<br>'
+ '관리자에게 문의하세요.';
} else {
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>이고, 이 브라우저에서는 '
+ '비밀번호 암호화를 사용할 수 없습니다.<br>'
+ '비밀번호가 <strong>암호화되지 않은 상태로</strong> 전송됩니다.';
}
showDialogQueued(message);
}
/**
* 이번 탭 세션에서 아직 경고를 안 띄웠으면 표시를 남기고 true 를 준다.
* 프라이빗 모드 등으로 sessionStorage 가 막혀 있으면 true — 안 띄우는 쪽보다 매번 띄우는 쪽이 안전하다.
*/
function markWarnedOnce() {
try {
if (!global.sessionStorage) {
return true;
}
if (global.sessionStorage.getItem(WARN_KEY) === '1') {
return false;
}
global.sessionStorage.setItem(WARN_KEY, '1');
} catch (e) {
// 접근 자체가 막힌 경우. 매번 띄운다.
}
return true;
}
// ---------- 선언적 훅 ----------
/**
* <form data-encrypt-fields="password,confirmPassword"> 를 만나면 submit 을 가로채
* 암호화 후 다시 제출한다. form.submit() 을 직접 호출하는 화면(로그인 등)은
* submit 이벤트가 발생하지 않으므로 encryptForm 을 명시적으로 호출해야 한다.
*/
function bindDeclarativeForms() {
if (!available()) {
return;
}
var forms = global.document.querySelectorAll('form[data-encrypt-fields]');
Array.prototype.forEach.call(forms, function (form) {
form.addEventListener('submit', function (event) {
if (form.getAttribute('data-encrypt-done') === 'true') {
form.removeAttribute('data-encrypt-done');
return;
}
// 페이지의 다른 submit 핸들러가 이미 제출을 막았다면(검증 실패 등)
// 여기서 재제출하면 그 검증을 우회하게 된다.
if (event.defaultPrevented) {
return;
}
var names = (form.getAttribute('data-encrypt-fields') || '')
.split(',')
.map(function (name) { return name.trim(); })
.filter(function (name) { return name.length > 0; });
if (names.length === 0) {
return;
}
event.preventDefault();
encryptForm(form, names).then(function () {
form.setAttribute('data-encrypt-done', 'true');
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
});
});
});
}
function start() {
bindDeclarativeForms();
warnInsecureTransport();
}
// DOMContentLoaded 가 아니라 load 시점에 건다. 페이지의 검증 핸들러는 대부분
// DOMContentLoaded/$(function) 에서 등록되므로, 그보다 늦게 등록해야 우리 리스너가 마지막에 실행되어
// 앞선 핸들러의 preventDefault(검증 실패)를 정확히 감지할 수 있다.
// 경고 팝업도 같은 시점이어야 custom-popups.js 와 팝업 프래그먼트가 준비된 뒤에 뜬다.
if (global.document) {
if (global.document.readyState === 'complete') {
start();
} else {
global.addEventListener('load', start);
}
}
global.portalPasswordCrypto = {
available: available,
engine: function () {
var current = available() ? engine() : null;
return current ? current.name : null;
},
encryptForm: encryptForm,
encryptValues: encryptValues
};
})(window);
@@ -91,7 +91,10 @@
var seg = document.getElementById('tfaSegment');
seg.innerHTML = '';
self._maskedByType = {};
var channels = info.channels || [];
// SMS를 먼저 표시하고 첫 번째 수단을 기본 선택한다.
var channels = (info.channels || []).slice().sort(function (a, b) {
return (a.type === 'SMS' ? 0 : 1) - (b.type === 'SMS' ? 0 : 1);
});
channels.forEach(function (ch, idx) {
self._maskedByType[ch.type] = ch.masked;
var btn = document.createElement('button');
File diff suppressed because one or more lines are too long
@@ -1,173 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* AUTO-GENERATED FILE. DO NOT MODIFY.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports);
} else {
// Browser globals
factory({});
}
})(this, function(exports) {
/**
* Language: English.
*/
var localeObj = {
time: {
month: [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
],
monthAbbr: [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
],
dayOfWeek: [
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
],
dayOfWeekAbbr: [
'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'
]
},
legend: {
selector: {
all: 'All',
inverse: 'Inv'
}
},
toolbox: {
brush: {
title: {
rect: 'Box Select',
polygon: 'Lasso Select',
lineX: 'Horizontally Select',
lineY: 'Vertically Select',
keep: 'Keep Selections',
clear: 'Clear Selections'
}
},
dataView: {
title: 'Data View',
lang: ['Data View', 'Close', 'Refresh']
},
dataZoom: {
title: {
zoom: 'Zoom',
back: 'Zoom Reset'
}
},
magicType: {
title: {
line: 'Switch to Line Chart',
bar: 'Switch to Bar Chart',
stack: 'Stack',
tiled: 'Tile'
}
},
restore: {
title: 'Restore'
},
saveAsImage: {
title: 'Save as Image',
lang: ['Right Click to Save Image']
}
},
series: {
typeNames: {
pie: 'Pie chart',
bar: 'Bar chart',
line: 'Line chart',
scatter: 'Scatter plot',
effectScatter: 'Ripple scatter plot',
radar: 'Radar chart',
tree: 'Tree',
treemap: 'Treemap',
boxplot: 'Boxplot',
candlestick: 'Candlestick',
k: 'K line chart',
heatmap: 'Heat map',
map: 'Map',
parallel: 'Parallel coordinate map',
lines: 'Line graph',
graph: 'Relationship graph',
sankey: 'Sankey diagram',
funnel: 'Funnel chart',
gauge: 'Gauge',
pictorialBar: 'Pictorial bar',
themeRiver: 'Theme River Map',
sunburst: 'Sunburst'
}
},
aria: {
general: {
withTitle: 'This is a chart about "{title}"',
withoutTitle: 'This is a chart'
},
series: {
single: {
prefix: '',
withName: ' with type {seriesType} named {seriesName}.',
withoutName: ' with type {seriesType}.'
},
multiple: {
prefix: '. It consists of {seriesCount} series count.',
withName: ' The {seriesId} series is a {seriesType} representing {seriesName}.',
withoutName: ' The {seriesId} series is a {seriesType}.',
separator: {
middle: '',
end: ''
}
}
},
data: {
allData: 'The data is as follows: ',
partialData: 'The first {displayCnt} items are: ',
withName: 'the data for {name} is {value}',
withoutName: '{value}',
separator: {
middle: ', ',
end: '. '
}
}
}
};
for (var key in localeObj) {
if (localeObj.hasOwnProperty(key)) {
exports[key] = localeObj[key];
}
}
});
@@ -1,169 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* AUTO-GENERATED FILE. DO NOT MODIFY.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
/**
* Language: English.
*/
var localeObj = {
time: {
month: [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
],
monthAbbr: [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
],
dayOfWeek: [
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
],
dayOfWeekAbbr: [
'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'
]
},
legend: {
selector: {
all: 'All',
inverse: 'Inv'
}
},
toolbox: {
brush: {
title: {
rect: 'Box Select',
polygon: 'Lasso Select',
lineX: 'Horizontally Select',
lineY: 'Vertically Select',
keep: 'Keep Selections',
clear: 'Clear Selections'
}
},
dataView: {
title: 'Data View',
lang: ['Data View', 'Close', 'Refresh']
},
dataZoom: {
title: {
zoom: 'Zoom',
back: 'Zoom Reset'
}
},
magicType: {
title: {
line: 'Switch to Line Chart',
bar: 'Switch to Bar Chart',
stack: 'Stack',
tiled: 'Tile'
}
},
restore: {
title: 'Restore'
},
saveAsImage: {
title: 'Save as Image',
lang: ['Right Click to Save Image']
}
},
series: {
typeNames: {
pie: 'Pie chart',
bar: 'Bar chart',
line: 'Line chart',
scatter: 'Scatter plot',
effectScatter: 'Ripple scatter plot',
radar: 'Radar chart',
tree: 'Tree',
treemap: 'Treemap',
boxplot: 'Boxplot',
candlestick: 'Candlestick',
k: 'K line chart',
heatmap: 'Heat map',
map: 'Map',
parallel: 'Parallel coordinate map',
lines: 'Line graph',
graph: 'Relationship graph',
sankey: 'Sankey diagram',
funnel: 'Funnel chart',
gauge: 'Gauge',
pictorialBar: 'Pictorial bar',
themeRiver: 'Theme River Map',
sunburst: 'Sunburst'
}
},
aria: {
general: {
withTitle: 'This is a chart about "{title}"',
withoutTitle: 'This is a chart'
},
series: {
single: {
prefix: '',
withName: ' with type {seriesType} named {seriesName}.',
withoutName: ' with type {seriesType}.'
},
multiple: {
prefix: '. It consists of {seriesCount} series count.',
withName: ' The {seriesId} series is a {seriesType} representing {seriesName}.',
withoutName: ' The {seriesId} series is a {seriesType}.',
separator: {
middle: '',
end: ''
}
}
},
data: {
allData: 'The data is as follows: ',
partialData: 'The first {displayCnt} items are: ',
withName: 'the data for {name} is {value}',
withoutName: '{value}',
separator: {
middle: ', ',
end: '. '
}
}
}
};
echarts.registerLocale('EN', localeObj);
});
@@ -1,173 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* AUTO-GENERATED FILE. DO NOT MODIFY.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports);
} else {
// Browser globals
factory({});
}
})(this, function(exports) {
/**
* Language: Korean.
*/
var localeObj = {
time: {
month: [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월'
],
monthAbbr: [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월'
],
dayOfWeek: [
'일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'
],
dayOfWeekAbbr: [
'일', '월', '화', '수', '목', '금', '토'
]
},
legend: {
selector: {
all: '모두 선택',
inverse: '선택 범위 반전'
}
},
toolbox: {
brush: {
title: {
rect: '사각형 선택',
polygon: '올가미 선택',
lineX: '수평 선택',
lineY: '수직 선택',
keep: '선택 유지',
clear: '선택 지우기'
}
},
dataView: {
title: '날짜 보기',
lang: ['날짜 보기', '닫기', '새로 고침']
},
dataZoom: {
title: {
zoom: '확대/축소',
back: '확대/축소 초기화'
}
},
magicType: {
title: {
line: '꺽은선 그래프로 변경',
bar: '막대 그래프로 변경',
stack: '스택',
tiled: '타일'
}
},
restore: {
title: '복구'
},
saveAsImage: {
title: '이미지로 저장',
lang: ['이미지를 저장하려면 마우스 오른쪽 버튼을 클릭하세요.']
}
},
series: {
typeNames: {
pie: '원 그래프',
bar: '막대 그래프',
line: '꺽은선 그래프',
scatter: '산점도',
effectScatter: '물결 효과 산점도',
radar: '방사형 그래프',
tree: '트리',
treemap: '트리맵',
boxplot: '상자 수염 그래프',
candlestick: '캔들스틱 차트',
k: 'K 라인 차트',
heatmap: '히트 맵',
map: '지도',
parallel: '평행 좌표 맵',
lines: '선',
graph: '관계 그래프',
sankey: '산키 다이어그램',
funnel: '깔때기형 그래프',
gauge: '계기',
pictorialBar: '픽토그램 차트',
themeRiver: '스트림 그래프',
sunburst: '선버스트 차트'
}
},
aria: {
general: {
withTitle: '"{title}"에 대한 차트입니다.',
withoutTitle: '차트입니다.'
},
series: {
single: {
prefix: '',
withName: ' 차트 유형은 {seriesType}이며 {seriesName}을 표시합니다.',
withoutName: ' 차트 유형은 {seriesType}입니다.'
},
multiple: {
prefix: '. {seriesCount} 하나의 차트 시리즈로 구성됩니다.',
withName: ' {seriesId}번째 시리즈는 {seriesName}을 나타내는 {seriesType} representing.',
withoutName: ' {seriesId}번째 시리즈는 {seriesType}입니다.',
separator: {
middle: '',
end: ''
}
}
},
data: {
allData: '데이터: ',
partialData: '첫번째 {displayCnt} 아이템: ',
withName: '{name}의 데이터는 {value}',
withoutName: '{value}',
separator: {
middle: ', ',
end: '. '
}
}
}
};
for (var key in localeObj) {
if (localeObj.hasOwnProperty(key)) {
exports[key] = localeObj[key];
}
}
});
@@ -1,169 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* AUTO-GENERATED FILE. DO NOT MODIFY.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
/**
* Language: Korean.
*/
var localeObj = {
time: {
month: [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월'
],
monthAbbr: [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월'
],
dayOfWeek: [
'일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'
],
dayOfWeekAbbr: [
'일', '월', '화', '수', '목', '금', '토'
]
},
legend: {
selector: {
all: '모두 선택',
inverse: '선택 범위 반전'
}
},
toolbox: {
brush: {
title: {
rect: '사각형 선택',
polygon: '올가미 선택',
lineX: '수평 선택',
lineY: '수직 선택',
keep: '선택 유지',
clear: '선택 지우기'
}
},
dataView: {
title: '날짜 보기',
lang: ['날짜 보기', '닫기', '새로 고침']
},
dataZoom: {
title: {
zoom: '확대/축소',
back: '확대/축소 초기화'
}
},
magicType: {
title: {
line: '꺽은선 그래프로 변경',
bar: '막대 그래프로 변경',
stack: '스택',
tiled: '타일'
}
},
restore: {
title: '복구'
},
saveAsImage: {
title: '이미지로 저장',
lang: ['이미지를 저장하려면 마우스 오른쪽 버튼을 클릭하세요.']
}
},
series: {
typeNames: {
pie: '원 그래프',
bar: '막대 그래프',
line: '꺽은선 그래프',
scatter: '산점도',
effectScatter: '물결 효과 산점도',
radar: '방사형 그래프',
tree: '트리',
treemap: '트리맵',
boxplot: '상자 수염 그래프',
candlestick: '캔들스틱 차트',
k: 'K 라인 차트',
heatmap: '히트 맵',
map: '지도',
parallel: '평행 좌표 맵',
lines: '선',
graph: '관계 그래프',
sankey: '산키 다이어그램',
funnel: '깔때기형 그래프',
gauge: '계기',
pictorialBar: '픽토그램 차트',
themeRiver: '스트림 그래프',
sunburst: '선버스트 차트'
}
},
aria: {
general: {
withTitle: '"{title}"에 대한 차트입니다.',
withoutTitle: '차트입니다.'
},
series: {
single: {
prefix: '',
withName: ' 차트 유형은 {seriesType}이며 {seriesName}을 표시합니다.',
withoutName: ' 차트 유형은 {seriesType}입니다.'
},
multiple: {
prefix: '. {seriesCount} 하나의 차트 시리즈로 구성됩니다.',
withName: ' {seriesId}번째 시리즈는 {seriesName}을 나타내는 {seriesType} representing.',
withoutName: ' {seriesId}번째 시리즈는 {seriesType}입니다.',
separator: {
middle: '',
end: ''
}
}
},
data: {
allData: '데이터: ',
partialData: '첫번째 {displayCnt} 아이템: ',
withName: '{name}의 데이터는 {value}',
withoutName: '{value}',
separator: {
middle: ', ',
end: '. '
}
}
}
};
echarts.registerLocale('KO', localeObj);
});
@@ -1,163 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#f2385a',
'#f5a503',
'#4ad9d9',
'#f7879c',
'#c1d7a8',
'#4dffd2',
'#fccfd7',
'#d5f6f6'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#f2385a'
}
},
visualMap: {
color: ['#f2385a', '#f5a503']
},
toolbox: {
color: ['#f2385a', '#f2385a', '#f2385a', '#f2385a']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#f2385a',
type: 'dashed'
},
crossStyle: {
color: '#f2385a'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#f2385a' // Handle color
},
timeline: {
lineStyle: {
color: '#f2385a'
},
controlStyle: {
color: '#f2385a',
borderColor: '#f2385a'
}
},
candlestick: {
itemStyle: {
color: '#f2385a',
color0: '#f5a503'
},
lineStyle: {
width: 1,
color: '#f2385a',
color0: '#f5a503'
},
areaStyle: {
color: '#c1d7a8',
color0: '#4ad9d9'
}
},
map: {
itemStyle: {
color: '#f2385a'
},
areaStyle: {
color: '#ddd'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#f2385a'
},
linkStyle: {
color: '#f2385a'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#f5a503'],
[0.8, '#f2385a'],
[1, '#c1d7a8']
],
width: 8
}
}
}
};
echarts.registerTheme('azul', theme);
});
@@ -1,178 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#001727',
'#805500',
'#ffff00',
'#ffd11a',
'#f2d71f',
'#f2be19',
'#f3a81a',
'#fff5cc'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#001727'
}
},
visualMap: {
color: ['#001727', '#805500']
},
toolbox: {
color: ['#001727', '#001727', '#001727', '#001727']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#001727',
type: 'dashed'
},
crossStyle: {
color: '#001727'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#001727' // Handle color
},
timeline: {
lineStyle: {
color: '#001727'
},
controlStyle: {
color: '#001727',
borderColor: '#001727'
}
},
candlestick: {
itemStyle: {
color: '#f3a81a',
color0: '#ffff00'
},
lineStyle: {
width: 1,
color: '#ffff00',
color0: '#f3a81a'
},
areaStyle: {
color: '#805500',
color0: '#ffff00'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#f3a81a',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#805500'
}
},
map: {
itemStyle: {
color: '#ffd11a'
},
areaStyle: {
color: '#f2be19'
},
label: {
color: '#ffd11a'
}
},
graph: {
itemStyle: {
color: '#001727'
},
linkStyle: {
color: '#001727'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#f2d71f'],
[0.8, '#001727'],
[1, '#ffff00']
],
width: 8
}
}
}
};
echarts.registerTheme('bee-inspired', theme);
});
@@ -1,178 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#1790cf',
'#1bb2d8',
'#99d2dd',
'#88b0bb',
'#1c7099',
'#038cc4',
'#75abd0',
'#afd6dd'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#1790cf'
}
},
visualMap: {
color: ['#1790cf', '#a2d4e6']
},
toolbox: {
color: ['#1790cf', '#1790cf', '#1790cf', '#1790cf']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#1790cf',
type: 'dashed'
},
crossStyle: {
color: '#1790cf'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(144,197,237,0.2)', // Fill the color
handleColor: '#1790cf' // Handle color
},
timeline: {
lineStyle: {
color: '#1790cfa'
},
controlStyle: {
color: '#1790cf',
borderColor: '#1790cf'
}
},
candlestick: {
itemStyle: {
color: '#1bb2d8',
color0: '#99d2dd'
},
lineStyle: {
width: 1,
color: '#1c7099',
color0: '#88b0bb'
},
areaStyle: {
color: '#1790cf',
color0: '#1bb2d8'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#1bb2d8',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#1790cf'
}
},
graph: {
itemStyle: {
color: '#1bb2d8'
},
linkStyle: {
color: '#88b0bb'
}
},
map: {
itemStyle: {
color: '#ddd'
},
areaStyle: {
color: '99d2dd'
},
label: {
color: '#c12e34'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#1bb2d8'],
[0.8, '#1790cf'],
[1, '#1c7099']
],
width: 8
}
}
}
};
echarts.registerTheme('blue', theme);
});
@@ -1,178 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#fad089',
'#ff9c5b',
'#f5634a',
'#ed303c',
'#3b8183',
'#f7826e',
'#faac9e',
'#fcd5cf'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#fad089'
}
},
visualMap: {
color: ['#fad089', '#a2d4e6']
},
toolbox: {
color: ['#fad089', '#fad089', '#fad089', '#fad089']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#fad089',
type: 'dashed'
},
crossStyle: {
color: '#fad089'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(144,197,237,0.2)', // Fill the color
handleColor: '#fad089' // Handle color
},
timeline: {
lineStyle: {
color: '#fad089'
},
controlStyle: {
color: '#fad089',
borderColor: '#fad089'
}
},
candlestick: {
itemStyle: {
color: '#ff9c5b',
color0: '#f5634a'
},
lineStyle: {
width: 1,
color: '#3b8183',
color0: '#ed303c'
},
areaStyle: {
color: '#fad089',
color0: '#ed303c'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#fad089',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#ed303c'
}
},
map: {
itemStyle: {
color: '#ddd'
},
areaStyle: {
color: '#f5634a'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#f5634a'
},
linkStyle: {
color: '#fad089'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#ff9c5b'],
[0.8, '#fad089'],
[1, '#3b8183']
],
width: 8
}
}
}
};
echarts.registerTheme('caravan', theme);
});
@@ -1,163 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#f0d8A8',
'#3d1c00',
'#86b8b1',
'#f2d694',
'#fa2a00',
'#ff8066',
'#ffd5cc',
'#f9edd2'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#f0d8A8'
}
},
visualMap: {
color: ['#f0d8A8', '#3d1c00']
},
toolbox: {
color: ['#f0d8A8', '#f0d8A8', '#f0d8A8', '#f0d8A8']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#f0d8A8',
type: 'dashed'
},
crossStyle: {
color: '#f0d8A8'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#f0d8A8' // Handle color
},
timeline: {
lineStyle: {
color: '#f0dba8'
},
controlStyle: {
color: '#f0dba8',
borderColor: '#f0dba8'
}
},
candlestick: {
itemStyle: {
color: '#3d1c00',
color0: '#86b8b1'
},
lineStyle: {
width: 1,
color: '#fa2a00',
color0: '#f2d694'
},
areaStyle: {
color: '#f0d8A8',
color0: '#86b8b1'
}
},
map: {
itemStyle: {
color: '#ddd'
},
areaStyle: {
color: '#86b8b1'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#3d1c00'
},
linkStyle: {
color: '#f0d8A8'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#3d1c00'],
[0.8, '#f0d8A8'],
[1, '#fa2a00']
],
width: 8
}
}
}
};
echarts.registerTheme('carp', theme);
});
@@ -1,180 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#b21ab4',
'#6f0099',
'#2a2073',
'#0b5ea8',
'#17aecc',
'#b3b3ff',
'#eb99ff',
'#fae6ff',
'#e6f2ff',
'#eeeeee'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#00aecd'
}
},
visualMap: {
color: ['#00aecd', '#a2d4e6']
},
toolbox: {
color: ['#00aecd', '#00aecd', '#00aecd', '#00aecd']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#00aecd',
type: 'dashed'
},
crossStyle: {
color: '#00aecd'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(144,197,237,0.2)', // Fill the color
handleColor: '#00aecd' // Handle color
},
timeline: {
lineStyle: {
color: '#00aecd'
},
controlStyle: {
color: '#00aecd',
borderColor: '00aecd'
}
},
candlestick: {
itemStyle: {
color: '#00aecd',
color0: '#a2d4e6'
},
lineStyle: {
width: 1,
color: '#00aecd',
color0: '#a2d4e6'
},
areaStyle: {
color: '#b21ab4',
color0: '#0b5ea8'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#b21ab4',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#0b5ea8'
}
},
graph: {
itemStyle: {
color: '#b21ab4'
},
linkStyle: {
color: '#2a2073'
}
},
map: {
itemStyle: {
color: '#c12e34'
},
areaStyle: {
color: '#ddd'
},
label: {
color: '#c12e34'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#dddddd'],
[0.8, '#00aecd'],
[1, '#f5ccff']
],
width: 8
}
}
}
};
echarts.registerTheme('cool', theme);
});
@@ -1,164 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var contrastColor = '#eee';
var axisCommon = function() {
return {
axisLine: {
lineStyle: {
color: contrastColor
}
},
axisTick: {
lineStyle: {
color: contrastColor
}
},
axisLabel: {
color: contrastColor
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#aaa'
}
},
splitArea: {
areaStyle: {
color: contrastColor
}
}
};
};
var colorPalette = [
'#00305a',
'#004b8d',
'#0074d9',
'#4192d9',
'#7abaf2',
'#99cce6',
'#d6ebf5',
'#eeeeee'
];
var theme = {
color: colorPalette,
backgroundColor: '#333',
tooltip: {
axisPointer: {
lineStyle: {
color: contrastColor
},
crossStyle: {
color: contrastColor
}
}
},
legend: {
textStyle: {
color: contrastColor
}
},
title: {
textStyle: {
color: contrastColor
}
},
toolbox: {
iconStyle: {
borderColor: contrastColor
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#00305a' // Handle color
},
timeline: {
itemStyle: {
color: colorPalette[1]
},
lineStyle: {
color: contrastColor
},
controlStyle: {
color: contrastColor,
borderColor: contrastColor
},
label: {
color: contrastColor
}
},
timeAxis: axisCommon(),
logAxis: axisCommon(),
valueAxis: axisCommon(),
categoryAxis: axisCommon(),
line: {
symbol: 'circle'
},
graph: {
color: colorPalette
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#004b8d'],
[0.8, '#00305a'],
[1, '#7abaf2']
],
width: 8
}
}
}
};
theme.categoryAxis.splitLine.show = false;
echarts.registerTheme('dark-blue', theme);
});
@@ -1,164 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var contrastColor = '#eee';
var axisCommon = function() {
return {
axisLine: {
lineStyle: {
color: contrastColor
}
},
axisTick: {
lineStyle: {
color: contrastColor
}
},
axisLabel: {
color: contrastColor
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#aaa'
}
},
splitArea: {
areaStyle: {
color: contrastColor
}
}
};
};
var colorPalette = [
'#458c6b',
'#f2da87',
'#d9a86c',
'#d94436',
'#a62424',
'#76bc9b',
'#cce6da',
'#eeeeee'
];
var theme = {
color: colorPalette,
backgroundColor: '#333',
tooltip: {
axisPointer: {
lineStyle: {
color: contrastColor
},
crossStyle: {
color: contrastColor
}
}
},
legend: {
textStyle: {
color: contrastColor
}
},
title: {
textStyle: {
color: contrastColor
}
},
toolbox: {
iconStyle: {
borderColor: contrastColor
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#458c6b' // Handle color
},
timeline: {
itemStyle: {
color: colorPalette[1]
},
lineStyle: {
color: contrastColor
},
controlStyle: {
color: contrastColor,
borderColor: contrastColor
},
label: {
color: contrastColor
}
},
timeAxis: axisCommon(),
logAxis: axisCommon(),
valueAxis: axisCommon(),
categoryAxis: axisCommon(),
line: {
symbol: 'circle'
},
graph: {
color: colorPalette
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#f2da87'],
[0.8, '#458c6b'],
[1, '#a62424']
],
width: 8
}
}
}
};
theme.categoryAxis.splitLine.show = false;
echarts.registerTheme('dark-bold', theme);
});
@@ -1,164 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var contrastColor = '#eee';
var axisCommon = function() {
return {
axisLine: {
lineStyle: {
color: contrastColor
}
},
axisTick: {
lineStyle: {
color: contrastColor
}
},
axisLabel: {
color: contrastColor
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#aaa'
}
},
splitArea: {
areaStyle: {
color: contrastColor
}
}
};
};
var colorPalette = [
'#52656b',
'#ff3b77',
'#a3cc00',
'#ffffff',
'#b8b89f',
'#ffccdb',
'#e5ff80',
'#f4f4f0'
];
var theme = {
color: colorPalette,
backgroundColor: '#333',
tooltip: {
axisPointer: {
lineStyle: {
color: contrastColor
},
crossStyle: {
color: contrastColor
}
}
},
legend: {
textStyle: {
color: contrastColor
}
},
title: {
textStyle: {
color: contrastColor
}
},
toolbox: {
iconStyle: {
borderColor: contrastColor
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#52656b' // Handle color
},
timeline: {
itemStyle: {
color: colorPalette[1]
},
lineStyle: {
color: contrastColor
},
controlStyle: {
color: contrastColor,
borderColor: contrastColor
},
label: {
color: contrastColor
}
},
timeAxis: axisCommon(),
logAxis: axisCommon(),
valueAxis: axisCommon(),
categoryAxis: axisCommon(),
line: {
symbol: 'circle'
},
graph: {
color: colorPalette
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#ff3b77'],
[0.8, '#52656b'],
[1, '#b8b89f']
],
width: 8
}
}
}
};
theme.categoryAxis.splitLine.show = false;
echarts.registerTheme('dark-digerati', theme);
});
@@ -1,164 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var contrastColor = '#eee';
var axisCommon = function() {
return {
axisLine: {
lineStyle: {
color: contrastColor
}
},
axisTick: {
lineStyle: {
color: contrastColor
}
},
axisLabel: {
color: contrastColor
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#aaa'
}
},
splitArea: {
areaStyle: {
color: contrastColor
}
}
};
};
var colorPalette = [
'#00a8c6',
'#40c0cb',
'#ebd3ad',
'#aee239',
'#8fbe00',
'#33e0ff',
'#b3f4ff',
'#e6ff99'
];
var theme = {
color: colorPalette,
backgroundColor: '#333',
tooltip: {
axisPointer: {
lineStyle: {
color: contrastColor
},
crossStyle: {
color: contrastColor
}
}
},
legend: {
textStyle: {
color: contrastColor
}
},
title: {
textStyle: {
color: contrastColor
}
},
toolbox: {
iconStyle: {
borderColor: contrastColor
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#00a8c6' // Handle color
},
timeline: {
itemStyle: {
color: colorPalette[1]
},
lineStyle: {
color: contrastColor
},
controlStyle: {
color: contrastColor,
borderColor: contrastColor
},
label: {
color: contrastColor
}
},
timeAxis: axisCommon(),
logAxis: axisCommon(),
valueAxis: axisCommon(),
categoryAxis: axisCommon(),
line: {
symbol: 'circle'
},
graph: {
color: colorPalette
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#40c0cb'],
[0.8, '#00a8c6'],
[1, '#8fbe00']
],
width: 8
}
}
}
};
theme.categoryAxis.splitLine.show = false;
echarts.registerTheme('dark-fresh-cut', theme);
});
@@ -1,164 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var contrastColor = '#eee';
var axisCommon = function() {
return {
axisLine: {
lineStyle: {
color: contrastColor
}
},
axisTick: {
lineStyle: {
color: contrastColor
}
},
axisLabel: {
color: contrastColor
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#aaa'
}
},
splitArea: {
areaStyle: {
color: contrastColor
}
}
};
};
var colorPalette = [
'#cc0e00',
'#ff1a0a',
'#ff8880',
'#ffc180',
'#ffc2b0',
'#ffffff',
'#ff8880',
'#ffe6e6'
];
var theme = {
color: colorPalette,
backgroundColor: '#333',
tooltip: {
axisPointer: {
lineStyle: {
color: contrastColor
},
crossStyle: {
color: contrastColor
}
}
},
legend: {
textStyle: {
color: contrastColor
}
},
title: {
textStyle: {
color: contrastColor
}
},
toolbox: {
iconStyle: {
borderColor: contrastColor
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#cc0e00' // Handle color
},
timeline: {
itemStyle: {
color: colorPalette[1]
},
lineStyle: {
color: contrastColor
},
controlStyle: {
color: contrastColor,
borderColor: contrastColor
},
label: {
color: contrastColor
}
},
timeAxis: axisCommon(),
logAxis: axisCommon(),
valueAxis: axisCommon(),
categoryAxis: axisCommon(),
line: {
symbol: 'circle'
},
graph: {
color: colorPalette
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#ff1a0a'],
[0.8, '#cc0e00'],
[1, '#ffc2b0']
],
width: 8
}
}
}
};
theme.categoryAxis.splitLine.show = false;
echarts.registerTheme('dark-mushroom', theme);
});
@@ -1,224 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var contrastColor = '#B9B8CE';
var backgroundColor = '#100C2A';
var axisCommon = function () {
return {
axisLine: {
lineStyle: {
color: contrastColor
}
},
splitLine: {
lineStyle: {
color: '#484753'
}
},
splitArea: {
areaStyle: {
color: ['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']
}
},
minorSplitLine: {
lineStyle: {
color: '#20203B'
}
}
};
};
var colorPalette = [
'#4992ff',
'#7cffb2',
'#fddd60',
'#ff6e76',
'#58d9f9',
'#05c091',
'#ff8a45',
'#8d48e3',
'#dd79ff'
];
var theme = {
darkMode: true,
color: colorPalette,
backgroundColor: backgroundColor,
axisPointer: {
lineStyle: {
color: '#817f91'
},
crossStyle: {
color: '#817f91'
},
label: {
// TODO Contrast of label backgorundColor
color: '#fff'
}
},
legend: {
textStyle: {
color: contrastColor
}
},
textStyle: {
color: contrastColor
},
title: {
textStyle: {
color: '#EEF1FA'
},
subtextStyle: {
color: '#B9B8CE'
}
},
toolbox: {
iconStyle: {
borderColor: contrastColor
}
},
dataZoom: {
borderColor: '#71708A',
textStyle: {
color: contrastColor
},
brushStyle: {
color: 'rgba(135,163,206,0.3)'
},
handleStyle: {
color: '#353450',
borderColor: '#C5CBE3'
},
moveHandleStyle: {
color: '#B0B6C3',
opacity: 0.3
},
fillerColor: 'rgba(135,163,206,0.2)',
emphasis: {
handleStyle: {
borderColor: '#91B7F2',
color: '#4D587D'
},
moveHandleStyle: {
color: '#636D9A',
opacity: 0.7
}
},
dataBackground: {
lineStyle: {
color: '#71708A',
width: 1
},
areaStyle: {
color: '#71708A'
}
},
selectedDataBackground: {
lineStyle: {
color: '#87A3CE'
},
areaStyle: {
color: '#87A3CE'
}
}
},
visualMap: {
textStyle: {
color: contrastColor
}
},
timeline: {
lineStyle: {
color: contrastColor
},
label: {
color: contrastColor
},
controlStyle: {
color: contrastColor,
borderColor: contrastColor
}
},
calendar: {
itemStyle: {
color: backgroundColor
},
dayLabel: {
color: contrastColor
},
monthLabel: {
color: contrastColor
},
yearLabel: {
color: contrastColor
}
},
timeAxis: axisCommon(),
logAxis: axisCommon(),
valueAxis: axisCommon(),
categoryAxis: axisCommon(),
line: {
symbol: 'circle'
},
graph: {
color: colorPalette
},
gauge: {
title: {
color: contrastColor
}
},
candlestick: {
itemStyle: {
color: '#FD1050',
color0: '#0CF49B',
borderColor: '#FD1050',
borderColor0: '#0CF49B'
}
}
};
theme.categoryAxis.splitLine.show = false;
echarts.registerTheme('dark', theme);
});
@@ -1,178 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#59535e',
'#e7dcef',
'#f1baf3',
'#5d4970',
'#372049',
'#c0b2cd',
'#ffccff',
'#f2f0f5'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#59535e'
}
},
visualMap: {
color: ['#59535e', '#e7dcef']
},
toolbox: {
color: ['#59535e', '#59535e', '#59535e', '#59535e']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#59535e',
type: 'dashed'
},
crossStyle: {
color: '#59535e'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#59535e' // Handle color
},
timeline: {
lineStyle: {
color: '#59535e'
},
controlStyle: {
color: '#59535e',
borderColor: '#59535e'
}
},
candlestick: {
itemStyle: {
color: '#e7dcef',
color0: '#f1baf3'
},
lineStyle: {
width: 1,
color: '#372049',
color0: '#5d4970'
},
areaStyle: {
color: '#59535e',
color0: '#e7dcef'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#59535e',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#e7dcef'
}
},
map: {
itemStyle: {
color: '#ddd'
},
areaStyle: {
color: '#f1baf3'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#59535e'
},
linkStyle: {
color: '#59535e'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#e7dcef'],
[0.8, '#59535e'],
[1, '#372049']
],
width: 8
}
}
}
};
echarts.registerTheme('eduardo', theme);
});
@@ -1,163 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#313b23',
'#494f2b',
'#606233',
'#d6b77b',
'#0e0e0e',
'#076278',
'#808080',
'#e7d5b1'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#313b23'
}
},
visualMap: {
color: ['#313b23', '#494f2b']
},
toolbox: {
color: ['#313b23', '#313b23', '#313b23', '#313b23']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#313b23',
type: 'dashed'
},
crossStyle: {
color: '#313b23'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#313b23' // Handle color
},
timeline: {
lineStyle: {
color: '#313b23'
},
controlStyle: {
color: '#313b23',
borderColor: '#313b23'
}
},
candlestick: {
itemStyle: {
color: '#494f2b',
color0: '#606233'
},
lineStyle: {
width: 1,
color: '#0e0e0e',
color0: '#d6b77b'
},
areaStyle: {
color: '#494f2b',
color0: '#d6b77b'
}
},
map: {
itemStyle: {
color: '#606233'
},
areaStyle: {
color: '#ddd'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#494f2b'
},
linkStyle: {
color: '#313b23'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#494f2b'],
[0.8, '#313b23'],
[1, '0e0e0e']
],
width: 8
}
}
}
};
echarts.registerTheme('forest', theme);
});
@@ -1,163 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#00a8c6',
'#40c0cb',
'#f0dec2',
'#aee239',
'#8fbe00',
'#33e0ff',
'#b3f4ff',
'#e6ff99'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#00a8c6'
}
},
visualMap: {
color: ['#00a8c6', '#a2d4e6']
},
toolbox: {
color: ['#00a8c6', '#00a8c6', '#00a8c6', '#00a8c6']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#00a8c6',
type: 'dashed'
},
crossStyle: {
color: '#00a8c6'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(144,197,237,0.2)', // Fill the color
handleColor: '#00a8c6' // Handle color
},
timeline: {
lineStyle: {
color: '#00a8c6'
},
controlStyle: {
color: '#00a8c6',
borderColor: '#00a8c6'
}
},
candlestick: {
itemStyle: {
color: '#40c0cb',
color0: '#f0dec2'
},
lineStyle: {
width: 1,
color: '#8fbe00',
color0: '#aee239'
},
areaStyle: {
color: '#00a8c6',
color0: '#aee239'
}
},
map: {
itemStyle: {
color: '#ddd'
},
areaStyle: {
color: '#f0dec2'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#f0dec2'
},
linkStyle: {
color: '#00a8c6'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#40c0cb'],
[0.8, '#00a8c6'],
[1, '#8fbe00']
],
width: 8
}
}
}
};
echarts.registerTheme('fresh-cut', theme);
});
@@ -1,178 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#ffcb6a',
'#ffa850',
'#ffe2c4',
'#e5834e',
'#ffb081',
'#f7826e',
'#faac9e',
'#fcd5cf'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#ffcb6a'
}
},
visualMap: {
color: ['#ffcb6a', '#ffa850']
},
toolbox: {
color: ['#ffcb6a', '#ffcb6a', '#ffcb6a', '#ffcb6a']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#ffcb6a',
type: 'dashed'
},
crossStyle: {
color: '#ffcb6a'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#ffcb6a' // Handle color
},
timeline: {
lineStyle: {
color: '#ffcb6a'
},
controlStyle: {
color: '#ffcb6a',
borderColor: '#ffcb6a'
}
},
candlestick: {
itemStyle: {
color: '#ffa850',
color0: '#ffe2c4'
},
lineStyle: {
width: 1,
color: '#ffb081',
color0: '#e5834e'
},
areaStyle: {
color: '#e5834e',
color0: '#fcd5cf'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#fcd5cf',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#e5834e'
}
},
map: {
itemStyle: {
color: '#ffe2c4'
},
areaStyle: {
color: '#ddd'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#f2385a'
},
linkStyle: {
color: '#ffcb6a'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#ffa850'],
[0.8, '#ffcb6a'],
[1, '#ffb081']
],
width: 8
}
}
}
};
echarts.registerTheme('fruit', theme);
});
@@ -1,220 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#757575',
'#c7c7c7',
'#dadada',
'#8b8b8b',
'#b5b5b5',
'#e9e9e9'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#757575'
}
},
dataRange: {
color: ['#636363', '#dcdcdc']
},
toolbox: {
color: ['#757575', '#757575', '#757575', '#757575']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#757575',
type: 'dashed'
},
crossStyle: {
color: '#757575'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(117,117,117,0.2)', // Fill the color
handleColor: '#757575' // Handle color
},
grid: {
borderWidth: 0
},
categoryAxis: {
axisLine: {
// Coordinate axis
lineStyle: {
// Property 'lineStyle' controls line styles
color: '#757575'
}
},
splitLine: {
// Separation line
lineStyle: {
// Property 'lineStyle' (see lineStyle) controls line styles
color: ['#eee']
}
}
},
valueAxis: {
axisLine: {
// Coordinate axis
lineStyle: {
// Property 'lineStyle' controls line styles
color: '#757575'
}
},
splitArea: {
show: true,
areaStyle: {
color: ['rgba(250,250,250,0.1)', 'rgba(200,200,200,0.1)']
}
},
splitLine: {
// Separation line
lineStyle: {
// Property 'lineStyle' (see lineStyle) controls line styles
color: ['#eee']
}
}
},
timeline: {
lineStyle: {
color: '#757575'
},
controlStyle: {
color: '#757575',
borderColor: '#757575'
}
},
candlestick: {
itemStyle: {
color: '#8b8b8b',
color0: '#dadada'
},
lineStyle: {
width: 1,
color: '#757575',
color0: '#c7c7c7'
},
areaStyle: {
color: '#757575',
color0: '#e9e9e9'
}
},
map: {
itemStyle: {
color: '#c7c7c7'
},
areaStyle: {
color: 'ddd'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#e9e9e9'
},
linkStyle: {
color: '#757575'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#e9e9e9',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#757575'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#b5b5b5'],
[0.8, '#757575'],
[1, '#5c5c5c']
],
width: 8
}
}
}
};
echarts.registerTheme('gray', theme);
});
@@ -1,222 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#408829',
'#68a54a',
'#a9cba2',
'#86b379',
'#397b29',
'#8abb6f',
'#759c6a',
'#bfd3b7'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#408829'
}
},
visualMap: {
color: ['408829', '#a9cba2']
},
toolbox: {
color: ['#408829', '#408829', '#408829', '#408829']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#408829',
type: 'dashed'
},
crossStyle: {
color: '#408829'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(64,136,41,0.2)', // Fill the color
handleColor: '#408829' // Handle color
},
grid: {
borderWidth: 0
},
categoryAxis: {
axisLine: {
// Coordinate axis
lineStyle: {
// Property 'lineStyle' controls line styles
color: '#408829'
}
},
splitLine: {
// Separation line
lineStyle: {
// Property 'lineStyle' (see lineStyle) controls line styles
color: ['#eee']
}
}
},
valueAxis: {
axisLine: {
// Coordinate axis
lineStyle: {
// Property 'lineStyle' controls line styles
color: '#408829'
}
},
splitArea: {
show: true,
areaStyle: {
color: ['rgba(250,250,250,0.1)', 'rgba(200,200,200,0.1)']
}
},
splitLine: {
// Separation line
lineStyle: {
// Property 'lineStyle' (see lineStyle) controls line styles
color: ['#eee']
}
}
},
timeline: {
lineStyle: {
color: '#408829'
},
controlStyle: {
color: '#408829',
borderColor: '#408829'
}
},
candlestick: {
itemStyle: {
color: '#68a54a',
color0: '#a9cba2'
},
lineStyle: {
width: 1,
color: '#408829',
color0: '#86b379'
},
areaStyle: {
color: '#408829',
color0: '#bfd3b7'
}
},
graph: {
itemStyle: {
color: '#bfd3b7'
},
linkStyle: {
color: '#408829'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#bfd3b7',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#408829'
}
},
map: {
itemStyle: {
color: '#ddd'
},
areaStyle: {
color: '#408829'
},
label: {
color: '#000'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#86b379'],
[0.8, '#68a54a'],
[1, '#408829']
],
width: 8
}
}
}
};
echarts.registerTheme('green', theme);
});
@@ -1,263 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#44B7D3',
'#E42B6D',
'#F4E24E',
'#FE9616',
'#8AED35',
'#ff69b4',
'#ba55d3',
'#cd5c5c',
'#ffa500',
'#40e0d0',
'#E95569',
'#ff6347',
'#7b68ee',
'#00fa9a',
'#ffd700',
'#6699FF',
'#ff6666',
'#3cb371',
'#b8860b',
'#30e0e0'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#8A826D'
}
},
dataRange: {
x: 'right',
y: 'center',
itemWidth: 5,
itemHeight: 25,
color: ['#E42B6D', '#F9AD96'],
text: ['High', 'Low'], // Text, default is numeric text
textStyle: {
color: '#8A826D' // Range text color
}
},
toolbox: {
color: ['#E95569', '#E95569', '#E95569', '#E95569'],
effectiveColor: '#ff4500',
itemGap: 8
},
tooltip: {
backgroundColor: 'rgba(138,130,109,0.7)', // Prompt background color, default is black with a transparency of 0.7
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#6B6455',
type: 'dashed'
},
crossStyle: {
color: '#A6A299'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: 'rgba(130,197,209,0.6)', // Data background color
fillerColor: 'rgba(233,84,105,0.1)', // Fill the color
handleColor: 'rgba(107,99,84,0.8)' // Handle color
},
grid: {
borderWidth: 0
},
categoryAxis: {
axisLine: {
// Coordinate axis
lineStyle: {
// Property 'lineStyle' controls line styles
color: '#6B6455'
}
},
splitLine: {
// separate line
show: false
}
},
valueAxis: {
axisLine: {
// Coordinate axis
show: true
},
splitArea: {
show: false
},
splitLine: {
// separate line
lineStyle: {
// Property 'lineStyle' controls line styles
color: ['#FFF'],
type: 'dashed'
}
}
},
polar: {
axisLine: {
// Coordinate axis
lineStyle: {
// // Property 'lineStyle' controls line styles
color: '#ddd'
}
},
splitArea: {
show: true,
areaStyle: {
color: ['rgba(250,250,250,0.2)', 'rgba(200,200,200,0.2)']
}
},
splitLine: {
lineStyle: {
color: '#ddd'
}
}
},
timeline: {
lineStyle: {
color: '#6B6455'
},
controlStyle: {
color: '#6B6455',
borderColor: '#6B6455'
}
},
line: {
smooth: true,
symbol: 'emptyCircle', // Inflection point graphic type
symbolSize: 3 // Inflection point graphic size
},
candlestick: {
itemStyle: {
color: '#e42B6d',
color0: '#44B7d3'
},
lineStyle: {
width: 1,
color: '#e42B6d',
color0: '#44B7d3'
},
areaStyle: {
color: '#fe994e',
color0: '#e42B6d'
}
},
map: {
itemStyle: {
color: '#6b6455'
},
areaStyle: {
color: '#ddd'
},
label: {
color: '#e42B6d'
}
},
graph: {
itemStyle: {
color: '#e42B6d'
},
linkStyle: {
color: '#6b6455'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#e42B6d',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#6b6455'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#44B7D3'],
[0.8, '#6B6455'],
[1, '#E42B6D']
],
width: 8
}
}
}
};
echarts.registerTheme('helianthus', theme);
});
@@ -1,236 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#C1232B',
'#27727B',
'#FCCE10',
'#E87C25',
'#B5C334',
'#FE8463',
'#9BCA63',
'#FAD860',
'#F3A43B',
'#60C0DD',
'#D7504B',
'#C6E579',
'#F4E001',
'#F0805A',
'#26C0C0'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#27727B'
}
},
visualMap: {
color: ['#C1232B', '#FCCE10']
},
toolbox: {
iconStyle: {
borderColor: colorPalette[0]
}
},
tooltip: {
backgroundColor: 'rgba(50,50,50,0.5)',
axisPointer: {
type: 'line',
lineStyle: {
color: '#27727B',
type: 'dashed'
},
crossStyle: {
color: '#27727B'
},
shadowStyle: {
color: 'rgba(200,200,200,0.3)'
}
}
},
dataZoom: {
dataBackgroundColor: 'rgba(181,195,52,0.3)',
fillerColor: 'rgba(181,195,52,0.2)',
handleColor: '#27727B'
},
categoryAxis: {
axisLine: {
lineStyle: {
color: '#27727B'
}
},
splitLine: {
show: false
}
},
valueAxis: {
axisLine: {
show: false
},
splitArea: {
show: false
},
splitLine: {
lineStyle: {
color: ['#ccc'],
type: 'dashed'
}
}
},
timeline: {
itemStyle: {
color: '#27727B'
},
lineStyle: {
color: '#27727B'
},
controlStyle: {
color: '#27727B',
borderColor: '#27727B'
},
symbol: 'emptyCircle',
symbolSize: 3
},
line: {
itemStyle: {
borderWidth: 2,
borderColor: '#fff',
lineStyle: {
width: 3
}
},
emphasis: {
itemStyle: {
borderWidth: 0
}
},
symbol: 'circle',
symbolSize: 3.5
},
candlestick: {
itemStyle: {
color: '#c1232b',
color0: '#b5c334'
},
lineStyle: {
width: 1,
color: '#c1232b',
color0: '#b5c334'
},
areaStyle: {
color: '#c1232b',
color0: '#27727b'
}
},
graph: {
itemStyle: {
color: '#c1232b'
},
linkStyle: {
color: '#b5c334'
}
},
map: {
itemStyle: {
color: '#f2385a',
areaColor: '#ddd',
borderColor: '#eee'
},
areaStyle: {
color: '#fe994e'
},
label: {
color: '#c1232b'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#B5C334'],
[0.8, '#27727B'],
[1, '#C1232B']
]
}
},
axisTick: {
splitNumber: 2,
length: 5,
lineStyle: {
color: '#fff'
}
},
axisLabel: {
color: '#fff'
},
splitLine: {
length: '5%',
lineStyle: {
color: '#fff'
}
},
title: {
offsetCenter: [0, -20]
}
}
};
echarts.registerTheme('infographic', theme);
});
@@ -1,163 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#cc0000',
'#002266',
'#ff9900',
'#006600',
'#8a150f',
'#076278',
'#808080',
'#f07b75'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#cc0000'
}
},
visualMap: {
color: ['#cc0000', '#002266']
},
toolbox: {
color: ['#cc0000', '#cc0000', '#cc0000', '#cc0000']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#cc0000',
type: 'dashed'
},
crossStyle: {
color: '#cc0000'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#cc0000' // Handle color
},
timeline: {
lineStyle: {
color: '#cc0000'
},
controlStyle: {
color: '#cc0000',
borderColor: '#cc0000'
}
},
candlestick: {
itemStyle: {
color: '#002266',
color0: '#ff9900'
},
lineStyle: {
width: 1,
color: '#8a150f',
color0: '#006600'
},
areaStyle: {
color: '#cc0000',
color0: '#ff9900'
}
},
map: {
itemStyle: {
color: '#ff9900'
},
areaStyle: {
color: '#ddd'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#ff9900'
},
linkStyle: {
color: '#cc0000'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#002266'],
[0.8, '#cc0000'],
[1, '8a150f']
],
width: 8
}
}
}
};
echarts.registerTheme('inspired', theme);
});
@@ -1,163 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#e9e0d1',
'#91a398',
'#33605a',
'#070001',
'#68462b',
'#58a79c',
'#abd3ce',
'#eef6f5'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#e9e0d1'
}
},
visualMap: {
color: ['#e9e0d1', '#91a398']
},
toolbox: {
color: ['#e9e0d1', '#e9e0d1', '#e9e0d1', '#e9e0d1']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#e9e0d1',
type: 'dashed'
},
crossStyle: {
color: '#e9e0d1'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#e9e0d1' // Handle color
},
timeline: {
lineStyle: {
color: '#e9e0d1'
},
controlStyle: {
color: '#e9e0d1',
borderColor: '#e9e0d1'
}
},
candlestick: {
itemStyle: {
color: '#91a398',
color0: '#33605a'
},
lineStyle: {
width: 1,
color: '#68462b',
color0: '#070001'
},
areaStyle: {
color: '#91a398',
color0: '#abd3ce'
}
},
map: {
itemStyle: {
color: '#c12e34'
},
areaStyle: {
color: '#ddd'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#33605a'
},
linkStyle: {
color: '#e9e0d1'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#91a398'],
[0.8, '#e9e0d1'],
[1, '#68462b']
],
width: 8
}
}
}
};
echarts.registerTheme('jazz', theme);
});
@@ -1,163 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#02151a',
'#043a47',
'#087891',
'#c8c8c8',
'#b31d14',
'#0b9cc1',
'#f2f2f2',
'#f07b75'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#02151a'
}
},
visualMap: {
color: ['#02151a', '#a2d4e6']
},
toolbox: {
color: ['#02151a', '#02151a', '#02151a', '#02151a']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#02151a',
type: 'dashed'
},
crossStyle: {
color: '#02151a'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(144,197,237,0.2)', // Fill the color
handleColor: '#02151a' // Handle color
},
timeline: {
lineStyle: {
color: '#02151a'
},
controlStyle: {
color: '#02151a',
borderColor: '#02151a'
}
},
candlestick: {
itemStyle: {
color: '#043a47',
color0: '#087891'
},
lineStyle: {
width: 1,
color: '#b31d14',
color0: '#c8c8c8'
},
areaStyle: {
color: '#087891',
color0: '#c8c8c8'
}
},
map: {
itemStyle: {
color: '#ddd'
},
areaStyle: {
color: '#087891'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#c12e34'
},
linkStyle: {
color: '#02151a'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#043a47'],
[0.8, '#02151a'],
[1, '#b31d14']
],
width: 8
}
}
}
};
echarts.registerTheme('london', theme);
});
@@ -1,242 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#2ec7c9',
'#b6a2de',
'#5ab1ef',
'#ffb980',
'#d87a80',
'#8d98b3',
'#e5cf0d',
'#97b552',
'#95706d',
'#dc69aa',
'#07a2a4',
'#9a7fd1',
'#588dd5',
'#f5994e',
'#c05050',
'#59678c',
'#c9ab00',
'#7eb00a',
'#6f5553',
'#c14089'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#008acd'
}
},
visualMap: {
itemWidth: 15,
color: ['#5ab1ef', '#e0ffff']
},
toolbox: {
iconStyle: {
borderColor: colorPalette[0]
}
},
tooltip: {
borderWidth: 0,
backgroundColor: 'rgba(50,50,50,0.5)',
textStyle: {
color: '#FFF'
},
axisPointer: {
type: 'line',
lineStyle: {
color: '#008acd'
},
crossStyle: {
color: '#008acd'
},
shadowStyle: {
color: 'rgba(200,200,200,0.2)'
}
}
},
dataZoom: {
dataBackgroundColor: '#efefff',
fillerColor: 'rgba(182,162,222,0.2)',
handleColor: '#008acd'
},
grid: {
borderColor: '#eee'
},
categoryAxis: {
axisLine: {
lineStyle: {
color: '#008acd'
}
},
splitLine: {
lineStyle: {
color: ['#eee']
}
}
},
valueAxis: {
axisLine: {
lineStyle: {
color: '#008acd'
}
},
splitArea: {
show: true,
areaStyle: {
color: ['rgba(250,250,250,0.1)', 'rgba(200,200,200,0.1)']
}
},
splitLine: {
lineStyle: {
color: ['#eee']
}
}
},
timeline: {
lineStyle: {
color: '#008acd'
},
controlStyle: {
color: '#008acd',
borderColor: '#008acd'
},
symbol: 'emptyCircle',
symbolSize: 3
},
line: {
smooth: true,
symbol: 'emptyCircle',
symbolSize: 3
},
candlestick: {
itemStyle: {
color: '#d87a80',
color0: '#2ec7c9'
},
lineStyle: {
width: 1,
color: '#d87a80',
color0: '#2ec7c9'
},
areaStyle: {
color: '#2ec7c9',
color0: '#b6a2de'
}
},
scatter: {
symbol: 'circle',
symbolSize: 4
},
map: {
itemStyle: {
color: '#ddd'
},
areaStyle: {
color: '#fe994e'
},
label: {
color: '#d87a80'
}
},
graph: {
itemStyle: {
color: '#d87a80'
},
linkStyle: {
color: '#2ec7c9'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#2ec7c9'],
[0.8, '#5ab1ef'],
[1, '#d87a80']
],
width: 10
}
},
axisTick: {
splitNumber: 10,
length: 15,
lineStyle: {
color: 'auto'
}
},
splitLine: {
length: 22,
lineStyle: {
color: 'auto'
}
},
pointer: {
width: 5
}
}
};
echarts.registerTheme('macarons', theme);
});
@@ -1,246 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#ed9678',
'#e7dac9',
'#cb8e85',
'#f3f39d',
'#c8e49c',
'#f16d7a',
'#f3d999',
'#d3758f',
'#dcc392',
'#2e4783',
'#82b6e9',
'#ff6347',
'#a092f1',
'#0a915d',
'#eaf889',
'#6699FF',
'#ff6666',
'#3cb371',
'#d5b158',
'#38b6b6'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#cb8e85'
}
},
dataRange: {
color: ['#cb8e85', '#e7dac9'], //颜色
//text:['高','低'], // 文本,默认为数值文本
textStyle: {
color: '#333' // 值域文字颜色
}
},
bar: {
barMinHeight: 0, // 最小高度改为0
// barWidth: null, // 默认自适应
barGap: '30%', // 柱间距离,默认为柱形宽度的30%,可设固定值
barCategoryGap: '20%', // 类目间柱形距离,默认为类目间距的20%,可设固定值
label: {
show: false
// position: 默认自适应,水平布局为'top',垂直布局为'right',可选为
// 'inside'|'left'|'right'|'top'|'bottom'
// textStyle: null // 默认使用全局文本样式,详见TEXTSTYLE
},
itemStyle: {
// color: '各异',
barBorderColor: '#fff', // 柱条边线
barBorderRadius: 0, // 柱条边线圆角,单位px,默认为0
barBorderWidth: 1 // 柱条边线线宽,单位px,默认为1
},
emphasis: {
itemStyle: {
// color: '各异',
barBorderColor: 'rgba(0,0,0,0)', // 柱条边线
barBorderRadius: 0, // 柱条边线圆角,单位px,默认为0
barBorderWidth: 1, // 柱条边线线宽,单位px,默认为1
},
label: {
show: false
// position: 默认自适应,水平布局为'top',垂直布局为'right',可选为
// 'inside'|'left'|'right'|'top'|'bottom'
// textStyle: null // 默认使用全局文本样式,详见TEXTSTYLE
}
}
},
line: {
label: {
show: false
// position: 默认自适应,水平布局为'top',垂直布局为'right',可选为
// 'inside'|'left'|'right'|'top'|'bottom'
// textStyle: null // 默认使用全局文本样式,详见TEXTSTYLE
},
itemStyle: {
// color: 各异,
},
emphasis: {
// color: 各异,
label: {
show: false
// position: 默认自适应,水平布局为'top',垂直布局为'right',可选为
// 'inside'|'left'|'right'|'top'|'bottom'
// textStyle: null // 默认使用全局文本样式,详见TEXTSTYLE
}
},
lineStyle: {
width: 2,
type: 'solid',
shadowColor: 'rgba(0,0,0,0)', //默认透明
shadowBlur: 5,
shadowOffsetX: 3,
shadowOffsetY: 3
},
//smooth : false,
//symbol: null, // 拐点图形类型
symbolSize: 2, // 拐点图形大小
//symbolRotate : null, // 拐点图形旋转控制
showAllSymbol: false // 标志图形默认只有主轴显示(随主轴标签间隔隐藏策略)
},
candlestick: {
itemStyle: {
color: '#fe9778',
color0: '#e7dac9'
},
lineStyle: {
width: 1,
color: '#f78766',
color0: '#f1ccb8'
},
areaStyle: {
color: '#e7dac9',
color0: '#c8e49c'
}
},
// 饼图默认参数
pie: {
center: ['50%', '50%'], // 默认全局居中
radius: [0, '75%'],
clockWise: false, // 默认逆时针
startAngle: 90,
minAngle: 0, // 最小角度改为0
selectedOffset: 10, // 选中是扇区偏移量
label: {
show: true,
position: 'outer',
color: '#1b1b1b',
lineStyle: { color: '#1b1b1b' }
// textStyle: null // 默认使用全局文本样式,详见TEXTSTYLE
},
itemStyle: {
// color: 各异,
borderColor: '#fff',
borderWidth: 1
},
labelLine: {
show: true,
length: 20,
lineStyle: {
// color: 各异,
width: 1,
type: 'solid'
}
}
},
map: {
itemStyle: {
color: '#ddd',
borderColor: '#fff',
borderWidth: 1
},
areaStyle: {
color: '#f3f39d'
},
label: {
show: false,
color: 'rgba(139,69,19,1)'
},
showLegendSymbol: true
},
graph: {
itemStyle: {
color: '#d87a80'
},
linkStyle: {
strokeColor: '#a17e6e'
},
nodeStyle: {
brushType: 'both',
strokeColor: '#a17e6e'
},
label: {
show: false
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#ed9678'],
[0.8, '#e7dac9'],
[1, '#cb8e85']
],
width: 8
}
}
}
};
echarts.registerTheme('macarons2', theme);
});
@@ -1,155 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#8aedd5',
'#93bc9e',
'#cef1db',
'#7fe579',
'#a6d7c2',
'#bef0bb',
'#99e2vb',
'#94f8a8',
'#7de5b8',
'#4dfb70'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#8aedd5'
}
},
toolbox: {
color: ['#8aedd5', '#8aedd5', '#8aedd5', '#8aedd5']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#8aedd5',
type: 'dashed'
},
crossStyle: {
color: '#8aedd5'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(64,136,41,0.2)', // Fill the color
handleColor: '#408829' // Handle color
},
dataRange: {
color: ['#93bc92', '#bef0bb']
},
candlestick: {
itemStyle: {
color: '#8aedd5',
color0: '#7fe579'
},
lineStyle: {
width: 1,
color: '#8aedd5',
color0: '#7fe579'
},
areaStyle: {
color: '#8aedd5',
color0: '#93bc9e'
}
},
graph: {
itemStyle: {
color: '#8aedd5'
},
linkStyle: {
color: '#93bc9e'
}
},
map: {
itemStyle: {
color: '#8aedd5'
},
areaStyle: {
color: '#93bc9e'
},
label: {
color: '#cef1db'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#93bc9e'],
[0.8, '#8aedd5'],
[1, '#a6d7c2']
],
width: 8
}
}
}
};
echarts.registerTheme('mint', theme);
});
@@ -1,163 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#8b1a2d',
'#a7314b',
'#e6004c',
'#ff8066',
'#8e5c4e',
'#ff1a66',
'#d6c582',
'#f0d4af'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#8b1a2d'
}
},
visualMap: {
color: ['#8b1a2d', '#a7314b']
},
toolbox: {
color: ['#8b1a2d', '#8b1a2d', '#8b1a2d', '#8b1a2d']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#8b1a2d',
type: 'dashed'
},
crossStyle: {
color: '#8b1a2d'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#8b1a2d' // Handle color
},
timeline: {
lineStyle: {
color: '#8b1a2d'
},
controlStyle: {
color: '#8b1a2d',
borderColor: '#8b1a2d'
}
},
candlestick: {
itemStyle: {
color: '#a7314b',
color0: '#d6c582'
},
lineStyle: {
width: 1,
color: '#8e5c4e',
color0: '#f0d4af'
},
areaStyle: {
color: '#8b1a2d',
color0: '#ff8066'
}
},
map: {
itemStyle: {
color: '#8b1a2d'
},
areaStyle: {
color: '#ff8066'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#ff8066'
},
linkStyle: {
color: '#8b1a2d'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#a7314b'],
[0.8, '#8b1a2d'],
[1, '#8e5c4e']
],
width: 8
}
}
}
};
echarts.registerTheme('red-velvet', theme);
});
@@ -1,225 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#d8361b',
'#f16b4c',
'#f7b4a9',
'#d26666',
'#99311c',
'#c42703',
'#d07e75'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#d8361b'
}
},
visualMap: {
color: ['#d8361b', '#ffd2d2']
},
dataRange: {
color: ['#bd0707', '#ffd2d2']
},
toolbox: {
color: ['#d8361b', '#d8361b', '#d8361b', '#d8361b']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#d8361b',
type: 'dashed'
},
crossStyle: {
color: '#d8361b'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(216,54,27,0.2)', // Fill the color
handleColor: '#d8361b' // Handle color
},
grid: {
borderWidth: 0
},
categoryAxis: {
axisLine: {
// Coordinate axis
lineStyle: {
// Property 'lineStyle' controls line styles
color: '#d8361b'
}
},
splitLine: {
// Separation line
lineStyle: {
// Property 'lineStyle' (see lineStyle) controls line styles
color: ['#eee']
}
}
},
valueAxis: {
axisLine: {
// Coordinate axis
lineStyle: {
// Property 'lineStyle' controls line styles
color: '#d8361b'
}
},
splitArea: {
show: true,
areaStyle: {
color: ['rgba(250,250,250,0.1)', 'rgba(200,200,200,0.1)']
}
},
splitLine: {
// Separation line
lineStyle: {
// Property 'lineStyle' (see lineStyle) controls line styles
color: ['#eee']
}
}
},
timeline: {
lineStyle: {
color: '#d8361b'
},
controlStyle: {
color: '#d8361b',
borderColor: '#d8361b'
}
},
candlestick: {
itemStyle: {
color: '#f16b4c',
color0: '#f7b4a9'
},
lineStyle: {
width: 1,
color: '#d8361b',
color0: '#d26666'
},
areaStyle: {
color: '#d8361b',
color0: '#d07e75'
}
},
graph: {
itemStyle: {
color: '#d07e75'
},
linkStyle: {
color: '#d8361b'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#d07e75',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#d8361b'
}
},
map: {
itemStyle: {
color: '#d8361b'
},
areaStyle: {
color: '#d07e75'
},
label: {
color: '#c12e34'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#f16b4c'],
[0.8, '#d8361b'],
[1, '#99311c']
],
width: 8
}
}
}
};
echarts.registerTheme('red', theme);
});

Some files were not shown because too many files have changed in this diff Show More