diff --git a/.gitignore b/.gitignore index 9fcbe2b..412056a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,9 @@ target .flattened-pom.xml **/.flattened-pom.xml + +### 本地环境 ### +node_modules/ +*.log +.omo/ +.codegraph/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..df52261 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,102 @@ +# AGENTS.md - tianai-captcha-enhanced + +## 项目概述 + +基于 tianai-captcha 1.5.5 开源版的增强版行为验证码,目标是复现增强版全部功能并自建验证码服务平台。 + +## 技术栈 + +| 组件 | 版本 | +|------|------| +| JDK | 21 (Microsoft OpenJDK 21.0.11 LTS) | +| Maven | 3.9.16 | +| Spring Boot | 3.4.5 | +| Lombok | 1.18.42 (后续逐步移除) | +| SLF4J | 2.0.17 | +| ONNX Runtime | 1.22.0 (ML推理,待引入) | + +## 环境路径 + +```powershell +$env:JAVA_HOME = "C:\Users\USER879511\.jdks\ms-21.0.11" +$env:PATH = "$env:JAVA_HOME\bin;$env:PATH" +$Maven = "D:\Middleware\environment\apache-maven-3.9.16\bin\mvn.cmd" +``` + +## 构建 & 验证 + +```powershell +$env:JAVA_HOME = "C:\Users\USER879511\.jdks\ms-21.0.11" +& "D:\Middleware\environment\apache-maven-3.9.16\bin\mvn.cmd" -f "D:\tianai-captcha-enhanced\pom.xml" compile +& "D:\Middleware\environment\apache-maven-3.9.16\bin\mvn.cmd" -f "D:\tianai-captcha-enhanced\pom.xml" package -DskipTests +``` + +## 项目版本 + +从 `1.5.5` 升级到 `2.0.0-SNAPSHOT`,表示这是增强版的开发版本。 + +## 模块结构 + +``` +tianai-captcha-enhanced/ +├── tianai-captcha/ # 核心模块 (JDK 21, Spring Boot 3.4) +├── tianai-captcha-springboot-starter/ # Spring Boot Starter (3.4.5) +├── tianai-captcha-web-sdk/ # 前端SDK (待增强) +├── tianai-captcha-platform/ # 管理平台后端 (待创建) +└── tianai-captcha-platform-ui/ # 管理平台前端 (待创建) +``` + +## 增强版功能计划 + +### 验证码类型 (对标官网16种) + +| 类型 | 常量 | 开源版 | 增强版 | 优先级 | +|------|------|:------:|:------:|:------:| +| 滑块验证 | SLIDER | ✅ | ✅ | P0 | +| 滑块验证V2 | SLIDER_V2 | ❌ | 🔜 | P1 | +| 曲线滑块 | CURVE_SLIDER | ❌ | 🔜 | P1 | +| 曲线滑块V2 | CURVE_SLIDER_V2 | ❌ | 🔜 | P2 | +| 曲线滑块V3 | CURVE_SLIDER_V3 | ❌ | 🔜 | P2 | +| 旋转验证 | ROTATE | ✅ | ✅ | P0 | +| 滑动还原 | CONCAT | ✅ | ✅ | P0 | +| 角度验证 | ANGLE | ❌ | 🔜 | P1 | +| 刮刮乐 | SCRATCH | ❌ | 🔜 | P1 | +| 文字点选 | WORD_IMAGE_CLICK | ✅ | ✅ | P0 | +| 图标点选 | ICON_CLICK | ❌ | 🔜 | P1 | +| 语序点选 | WORD_ORDER_CLICK | ❌ | 🔜 | P1 | +| 乱序拼图 | JIGSAW | ❌ | 🔜 | P1 | +| 曲线绘制 | CURVE_DRAW | ❌ | 🔜 | P2 | +| 工作量证明 | PROOF_OF_WORK | ❌ | 🔜 | P2 | +| 随机 | RANDOM | ✅ | ✅ | P0 | + +### 增强功能 (对标付费版10项) + +| 功能 | 优先级 | 状态 | +|------|:------:|:----:| +| 全新加密算法 (AES-256 + RSA-4096 + SHA256) | P0 | 🔜 | +| ML轨迹校验器 (规则引擎 + ONNX) | P0 | 🔜 | +| 位置校验 (动态容错) | P0 | ✅ 已有基础 | +| 验证码背景乱序 | P1 | 🔜 | +| 差异校验 (防重放) | P1 | 🔜 | +| 请求次数校验 (滑动窗口限流) | P1 | 🔜 | +| 点选类图片背景扭曲 | P1 | 🔜 | +| IP禁用 | P1 | 🔜 | +| 轨迹相似性校验 (DTW) | P2 | 🔜 | +| 轨迹段落区分 | P2 | 🔜 | + +### 平台化功能 + +| 功能 | 状态 | +|------|:----:| +| 站点管理 (SiteKey/SecretKey) | 🔜 | +| verifyToken 一次性消费 | 🔜 | +| 前端SDK (兼容TPCaptcha API) | 🔜 | +| 管理后台 | 🔜 | +| 统计监控 | 🔜 | + +## 编码规范 + +- 包结构遵循开源版: application/generator/validator/resource/interceptor/cache/common +- 新增包: crypto/obfuscator/risk/site/ml +- API 响应统一使用 ApiResponse +- 暂时保留 Lombok,后续逐步替换为手写 getter/setter diff --git a/ml-model/track-rules.json b/ml-model/track-rules.json new file mode 100644 index 0000000..fd623d1 --- /dev/null +++ b/ml-model/track-rules.json @@ -0,0 +1 @@ +[{"name":"speedPhaseCorrelation","weight":0.6909424674992277,"hMean":0.955714239708839,"bMean":0.37967247512170127,"hStd":0.7442769634838727,"bStd":0.7631401323344459,"lowThreshold":-1.1466077895471904,"highThreshold":1.905952739790593,"separation":0.38188493499845555},{"name":"yDirectionChanges","weight":0.6565594255113425,"hMean":0.16129032258064516,"bMean":0.0,"hStd":0.5141089500164266,"bStd":0.0,"lowThreshold":0.0,"highThreshold":0.0,"separation":0.31311885102268494},{"name":"startOffset","weight":0.6133086990872181,"hMean":54.39618866626594,"bMean":14.656055315776996,"hStd":125.3553265098474,"bStd":50.005908122726716,"lowThreshold":-85.35576092967644,"highThreshold":114.66787156123043,"separation":0.22661739817443616},{"name":"xUniformity","weight":0.6037572020042908,"hMean":41.97851401736458,"bMean":16.237024125419424,"hStd":93.48773946787334,"bStd":30.558022562586117,"lowThreshold":-44.87902099975281,"highThreshold":77.35306925059166,"separation":0.2075144040085817},{"name":"speedVariance","weight":0.5856956110673432,"hMean":74.39660229654821,"bMean":530.9315858041267,"hStd":295.3999286413061,"bStd":2368.3001513872487,"lowThreshold":-516.403254986064,"highThreshold":665.1964595791603,"separation":0.17139122213468655},{"name":"accelerationVariance","weight":0.5814165154169918,"hMean":128.33025463499524,"bMean":1173.3393963589565,"hStd":549.7412804607712,"bStd":5867.930696402256,"lowThreshold":-971.1523062865472,"highThreshold":1227.8128155565378,"separation":0.16283303083398368},{"name":"totalDuration","weight":0.5686726385595083,"hMean":3488.8387096774195,"bMean":33173.4367816092,"hStd":3768.1041018491687,"bStd":212363.08407772906,"lowThreshold":-4047.369494020918,"highThreshold":11025.046913375758,"separation":0.13734527711901662},{"name":"pauses","weight":0.568510610386744,"hMean":6.32258064516129,"bMean":4.873563218390805,"hStd":4.222727270181567,"bStd":6.35140422106299,"lowThreshold":-7.829245223735175,"highThreshold":17.576371660516784,"separation":0.13702122077348794},{"name":"pathEfficiency","weight":0.5649463332759833,"hMean":0.765124453338266,"bMean":0.6726903175221292,"hStd":0.32528093053197915,"bStd":0.38533842620173075,"lowThreshold":-0.09798653488133235,"highThreshold":1.4433671699255908,"separation":0.12989266655196666},{"name":"overshootRatio","weight":0.5606943125493219,"hMean":0.10818778344940755,"bMean":5.116671776291968,"hStd":0.3023070391177473,"bStd":40.95660498948067,"lowThreshold":-0.4964262947860871,"highThreshold":0.7128018616849021,"separation":0.12138862509864388},{"name":"totalPoints","weight":0.5332468203395597,"hMean":76.93548387096774,"bMean":89.47126436781609,"hStd":71.92017774381091,"bStd":116.60482818422503,"lowThreshold":-66.90487161665408,"highThreshold":220.77583935858956,"separation":0.06649364067911935},{"name":"speedSkewness","weight":0.531573374575929,"hMean":2.6601663008886245,"bMean":2.2463177619223123,"hStd":3.3705027899373414,"bStd":3.1822556303270444,"lowThreshold":-4.1181934987317765,"highThreshold":8.6108290225764,"separation":0.06314674915185799},{"name":"maxJumpDistance","weight":0.5295855247419992,"hMean":113.78601562767373,"bMean":97.8237766984338,"hStd":158.22646306898454,"bStd":111.53687385656968,"lowThreshold":-125.24997101470557,"highThreshold":320.89752441157316,"separation":0.059171049483998206},{"name":"straightness","weight":0.503498502203403,"hMean":0.16895565571775595,"bMean":0.17599076422921264,"hStd":0.6278592842222325,"bStd":0.37658648977407716,"lowThreshold":-1.086762912726709,"highThreshold":1.4246742241622208,"separation":0.0069970044068060466}] \ No newline at end of file diff --git a/tianai-captcha-platform-ui/index.html b/tianai-captcha-platform-ui/index.html new file mode 100644 index 0000000..99fd67c --- /dev/null +++ b/tianai-captcha-platform-ui/index.html @@ -0,0 +1,12 @@ + + + + + + tianai-captcha 管理平台 + + +
+ + + diff --git a/tianai-captcha-platform-ui/logo/logo.html b/tianai-captcha-platform-ui/logo/logo.html new file mode 100644 index 0000000..d782e95 --- /dev/null +++ b/tianai-captcha-platform-ui/logo/logo.html @@ -0,0 +1,196 @@ + + + + + 史诗质感 - 真·双剑与盾行为验证 (强效魔法脉冲版) + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ + + + \ No newline at end of file diff --git a/tianai-captcha-platform-ui/logo/logo.svg b/tianai-captcha-platform-ui/logo/logo.svg new file mode 100644 index 0000000..25372ac --- /dev/null +++ b/tianai-captcha-platform-ui/logo/logo.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tianai-captcha-platform-ui/logo/logo_v2.html b/tianai-captcha-platform-ui/logo/logo_v2.html new file mode 100644 index 0000000..e6098ec --- /dev/null +++ b/tianai-captcha-platform-ui/logo/logo_v2.html @@ -0,0 +1,191 @@ + + + + + 史诗质感 - 真·双剑与盾行为验证 (锐利光环版) + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ + + + \ No newline at end of file diff --git a/tianai-captcha-platform-ui/package-lock.json b/tianai-captcha-platform-ui/package-lock.json new file mode 100644 index 0000000..485d3bf --- /dev/null +++ b/tianai-captcha-platform-ui/package-lock.json @@ -0,0 +1,1966 @@ +{ + "name": "tianai-captcha-platform-ui", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tianai-captcha-platform-ui", + "version": "2.0.0", + "dependencies": { + "axios": "^1.7.0", + "naive-ui": "^2.40.0", + "pinia": "^2.2.0", + "vue": "^3.5.0", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.0", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@css-render/plugin-bem": { + "version": "0.15.14", + "resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", + "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", + "license": "MIT", + "peerDependencies": { + "css-render": "~0.15.14" + } + }, + "node_modules/@css-render/vue3-ssr": { + "version": "0.15.14", + "resolved": "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", + "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/css-render": { + "version": "0.15.14", + "resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz", + "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "~0.8.0", + "csstype": "~3.0.5" + } + }, + "node_modules/css-render/node_modules/csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/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/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/evtd": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/evtd/-/evtd-0.2.4.tgz", + "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/naive-ui": { + "version": "2.44.1", + "resolved": "https://registry.npmmirror.com/naive-ui/-/naive-ui-2.44.1.tgz", + "integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==", + "license": "MIT", + "dependencies": { + "@css-render/plugin-bem": "^0.15.14", + "@css-render/vue3-ssr": "^0.15.14", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "async-validator": "^4.2.5", + "css-render": "^0.15.14", + "csstype": "^3.1.3", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "evtd": "^0.2.4", + "highlight.js": "^11.8.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "seemly": "^0.3.10", + "treemate": "^0.3.11", + "vdirs": "^0.1.8", + "vooks": "^0.2.12", + "vueuc": "^0.4.65" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/seemly": { + "version": "0.3.10", + "resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz", + "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/treemate": { + "version": "0.3.11", + "resolved": "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz", + "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==", + "license": "MIT" + }, + "node_modules/vdirs": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz", + "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vooks": { + "version": "0.2.12", + "resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz", + "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vueuc": { + "version": "0.4.65", + "resolved": "https://registry.npmmirror.com/vueuc/-/vueuc-0.4.65.tgz", + "integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==", + "license": "MIT", + "dependencies": { + "@css-render/vue3-ssr": "^0.15.10", + "@juggle/resize-observer": "^3.3.1", + "css-render": "^0.15.10", + "evtd": "^0.2.4", + "seemly": "^0.3.6", + "vdirs": "^0.1.4", + "vooks": "^0.2.4" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + } + } +} diff --git a/tianai-captcha-platform-ui/package.json b/tianai-captcha-platform-ui/package.json new file mode 100644 index 0000000..26d0f3a --- /dev/null +++ b/tianai-captcha-platform-ui/package.json @@ -0,0 +1,22 @@ +{ + "name": "tianai-captcha-platform-ui", + "version": "2.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.0", + "naive-ui": "^2.40.0", + "axios": "^1.7.0", + "vue-router": "^4.4.0", + "pinia": "^2.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.0", + "vite": "^6.0.0" + } +} diff --git a/tianai-captcha-platform-ui/public/logo/logo.svg b/tianai-captcha-platform-ui/public/logo/logo.svg new file mode 100644 index 0000000..25372ac --- /dev/null +++ b/tianai-captcha-platform-ui/public/logo/logo.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tianai-captcha-platform-ui/public/logo/浅色模式.png b/tianai-captcha-platform-ui/public/logo/浅色模式.png new file mode 100644 index 0000000..14d55e6 Binary files /dev/null and b/tianai-captcha-platform-ui/public/logo/浅色模式.png differ diff --git a/tianai-captcha-platform-ui/public/logo/深色模式.png b/tianai-captcha-platform-ui/public/logo/深色模式.png new file mode 100644 index 0000000..b8b6106 Binary files /dev/null and b/tianai-captcha-platform-ui/public/logo/深色模式.png differ diff --git a/tianai-captcha-platform-ui/public/sdk/captcha.js b/tianai-captcha-platform-ui/public/sdk/captcha.js new file mode 100644 index 0000000..cac12f7 --- /dev/null +++ b/tianai-captcha-platform-ui/public/sdk/captcha.js @@ -0,0 +1,746 @@ +/** + * tianai-captcha-enhanced SDK + * 兼容 TPCaptcha API (TPCaptcha.init / onSuccess / verifyToken) + * + * Usage: + * TACaptcha.init({ + * elId: 'captcha-box', + * siteKey: 'your-site-key', + * type: 'SLIDER', // optional + * scene: 'login', // optional + * serverUrl: 'https://your-server/api', // optional + * onSuccess: (result) => { console.log(result.data.verifyToken); }, + * onFail: () => { console.log('failed'); } + * }); + */ +(function (global) { + 'use strict'; + + const INSTANCES = new Map(); + + class TACaptchaInstance { + constructor(options) { + this.options = Object.assign({ + elId: null, + siteKey: '', + mode: 'click', + type: null, + scene: 'default', + serverUrl: '/api', + onSuccess: null, + onFail: null, + onOpen: null, + onClose: null, + onRefresh: null, + }, options); + + this.container = null; + this.modal = null; + this.captchaData = null; + this.verifyToken = null; + this.isDragging = false; + this.startX = 0; + this.currentX = 0; + this.trackList = []; + this.startTime = 0; + this.isDark = this._detectTheme(); + this._themeObserver = null; + + this._init(); + } + + _detectTheme() { + if (this.options.theme === 'dark') return true; + if (this.options.theme === 'light') return false; + if (document.body.classList.contains('dark') || document.documentElement.classList.contains('dark')) return true; + if (document.documentElement.getAttribute('data-theme') === 'dark') return true; + if (document.documentElement.getAttribute('color-scheme') === 'dark') return true; + if (document.documentElement.style.colorScheme === 'dark') return true; + return false; + } + + _watchTheme() { + if (this._themeObserver) this._themeObserver.disconnect(); + const targets = [document.body, document.documentElement]; + this._themeObserver = new MutationObserver(() => { + const newDark = this._detectTheme(); + if (newDark !== this.isDark) { + this.isDark = newDark; + this._createTriggerButton(); + } + }); + targets.forEach(t => { + if (t) this._themeObserver.observe(t, { attributes: true, attributeFilter: ['class', 'data-theme', 'color-scheme'] }); + }); + if (window.matchMedia) { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + this._mqHandler = () => { + const newDark = this._detectTheme(); + if (newDark !== this.isDark) { + this.isDark = newDark; + this._createTriggerButton(); + } + }; + mq.addEventListener('change', this._mqHandler); + } + } + + _init() { + if (this.options.mode === 'event') { + this._createModal(); + return; + } + this._createTriggerButton(); + this._createModal(); + this._watchTheme(); + } + + _createTriggerButton() { + const el = document.getElementById(this.options.elId); + if (!el) return; + this.container = el; + + const typeLabel = this.options.type || '智能验证'; + + // 注入呼吸灯 + 盾牌动画样式 + if (!document.getElementById('tacaptcha-breathing-style')) { + const style = document.createElement('style'); + style.id = 'tacaptcha-breathing-style'; + style.textContent = + '@keyframes tacaptcha-breathe{0%,100%{box-shadow:0 0 0 0 rgba(82,196,26,0.4)}50%{box-shadow:0 0 0 6px rgba(82,196,26,0)}}' + + '.tacaptcha-dot{width:8px;height:8px;border-radius:50%;background:#52c41a;animation:tacaptcha-breathe 2s ease-in-out infinite}' + + '.tacaptcha-shield .shield-shard{transform-origin:50px 50px;transition:transform 2s cubic-bezier(0.22,1,0.36,1),fill 2s ease,opacity 2s ease}' + + '.tacaptcha-shield .ward-group{transition:opacity 2s ease}' + + '.tacaptcha-shield .ward-pulse{transform-origin:50px 50px;animation:pulse-ward 2s cubic-bezier(0.1,0.7,0.3,1) infinite}' + + '.tacaptcha-shield .ward-pulse.delay{animation-delay:1s}' + + '@keyframes pulse-ward{0%{transform:scale(0.6);opacity:1;stroke-width:6px}100%{transform:scale(1.8);opacity:0;stroke-width:1px}}' + + '.tacaptcha-shield .sword-slide{transform:translateY(-85px);opacity:0;transition:transform 2s cubic-bezier(0.175,0.885,0.32,1.4),opacity 2s ease}' + + '.tacaptcha-shield .sword-anchor-a{transform-origin:50px 50px;transform:rotate(-45deg)}' + + '.tacaptcha-shield .sword-anchor-b{transform-origin:50px 50px;transform:rotate(45deg)}' + + '.tacaptcha-shield.state-wait .ward-group{opacity:1}' + + '.tacaptcha-shield.state-success .ward-group,.tacaptcha-shield.state-fail .ward-group{opacity:0}' + + '.tacaptcha-shield.state-success .ward-pulse,.tacaptcha-shield.state-fail .ward-pulse{animation:none}' + + '.tacaptcha-shield.state-wait .shield-shard{fill:url(#tacaptcha-mb);transform:translate(0,0) scale(1) rotate(0deg);opacity:1;filter:drop-shadow(0 6px 8px rgba(0,0,0,0.6))}' + + '.tacaptcha-shield.state-success .shield-shard{fill:url(#tacaptcha-mg);transform:translate(0,0) scale(1) rotate(0deg);opacity:1;filter:drop-shadow(0 6px 8px rgba(0,0,0,0.6))}' + + '.tacaptcha-shield.state-success .sword-slide{transform:translateY(-15px);opacity:1}' + + '.tacaptcha-shield.state-fail .shield-shard{fill:url(#tacaptcha-mr);opacity:0.9;filter:none}' + + '.tacaptcha-shield.state-fail .shard-tl{transform:translate(-25px,-30px) scale(0.9) rotateX(30deg) rotateY(-20deg) rotateZ(-15deg);opacity:0.7}' + + '.tacaptcha-shield.state-fail .shard-tr{transform:translate(25px,-30px) scale(0.9) rotateX(30deg) rotateY(20deg) rotateZ(15deg);opacity:0.7}' + + '.tacaptcha-shield.state-fail .shard-b{transform:translate(0,35px) scale(0.8) rotateX(-20deg) rotateZ(5deg);opacity:0.5}'; + document.head.appendChild(style); + } + + const bgColor = this.isDark ? '#1a1a2e' : '#fff'; + const borderColor = this.isDark ? '#2a2a4a' : '#e8e8e8'; + const textColor = this.isDark ? '#e0e0e0' : '#333'; + const subColor = this.isDark ? '#888' : '#999'; + const btnBg = this.isDark ? '#16213e' : '#fafafa'; + const btnBorder = this.isDark ? '#0f3460' : '#d9d9d9'; + const btnColor = this.isDark ? '#aaa' : '#666'; + const shadow = this.isDark ? '0 2px 8px rgba(0,0,0,0.3)' : '0 1px 4px rgba(0,0,0,0.06)'; + + const shieldSvg = this._createShieldSvg('state-wait'); + + const btn = document.createElement('div'); + btn.style.cssText = 'display:flex;align-items:center;gap:10px;padding:12px 16px;border:1px solid ' + borderColor + ';border-radius:8px;cursor:pointer;user-select:none;transition:all 0.2s;font-size:14px;color:' + textColor + ';background:' + bgColor + ';box-shadow:' + shadow + ';'; + btn.innerHTML = + shieldSvg + + '点击进行验证' + + '' + typeLabel + '' + + '' + + '点击验证'; + + btn.addEventListener('mouseenter', () => { btn.style.borderColor = '#1890ff'; }); + btn.addEventListener('mouseleave', () => { btn.style.borderColor = borderColor; }); + btn.addEventListener('click', () => this._openCaptcha()); + + el.appendChild(btn); + this.triggerBtn = btn; + } + + _createShieldSvg(stateClass) { + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + + _setShieldState(state) { + if (!this.triggerBtn) return; + const svg = this.triggerBtn.querySelector('.tacaptcha-shield'); + if (svg) { + svg.classList.remove('state-wait', 'state-success', 'state-fail'); + const pulses = svg.querySelectorAll('.ward-pulse'); + pulses.forEach(p => { p.style.animation = 'none'; }); + void svg.offsetWidth; + svg.classList.add('state-' + state); + } + } + + _createModal() { + const panelBg = this.isDark ? '#1a1a2e' : '#fff'; + const textColor = this.isDark ? '#e0e0e0' : '#333'; + const subColor = this.isDark ? '#666' : '#999'; + const overlayBg = this.isDark ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.3)'; + const shadow = this.isDark ? '0 4px 20px rgba(0,0,0,0.4)' : '0 4px 20px rgba(0,0,0,0.15)'; + + this.modal = document.createElement('div'); + this.modal.style.cssText = 'display:none;position:fixed;top:0;left:0;width:100%;height:100%;z-index:10000;'; + + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;background:' + overlayBg + ';'; + overlay.addEventListener('click', () => this._closeCaptcha()); + + const panel = document.createElement('div'); + panel.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:' + panelBg + ';border-radius:8px;padding:20px;box-shadow:' + shadow + ';min-width:340px;'; + + this.captchaPanel = panel; + + const header = document.createElement('div'); + header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;'; + header.innerHTML = '请完成安全验证'; + + const refreshBtn = document.createElement('span'); + refreshBtn.textContent = '↻'; + refreshBtn.style.cssText = 'cursor:pointer;font-size:18px;color:' + subColor + ';'; + refreshBtn.addEventListener('click', () => this._loadCaptcha()); + header.appendChild(refreshBtn); + + const closeBtn = document.createElement('span'); + closeBtn.textContent = '✕'; + closeBtn.style.cssText = 'cursor:pointer;font-size:16px;color:' + subColor + ';margin-left:12px;'; + closeBtn.addEventListener('click', () => this._closeCaptcha()); + header.appendChild(closeBtn); + + this.captchaContent = document.createElement('div'); + this.captchaContent.style.cssText = 'position:relative;'; + + this.statusBar = document.createElement('div'); + this.statusBar.style.cssText = 'margin-top:12px;text-align:center;font-size:13px;color:' + subColor + ';'; + + panel.appendChild(header); + panel.appendChild(this.captchaContent); + panel.appendChild(this.statusBar); + + this.modal.appendChild(overlay); + this.modal.appendChild(panel); + document.body.appendChild(this.modal); + } + + async _openCaptcha() { + this.modal.style.display = 'block'; + if (this.options.onOpen) this.options.onOpen(); + await this._loadCaptcha(); + } + + _closeCaptcha() { + this.modal.style.display = 'none'; + if (this.options.onClose) this.options.onClose(); + } + + async _loadCaptcha() { + this.statusBar.textContent = '加载中...'; + this.captchaContent.innerHTML = ''; + this.verifyToken = null; + this._setShieldState('wait'); + + try { + const url = `${this.options.serverUrl}/challenge/generate`; + const params = new URLSearchParams(); + if (this.options.type) params.set('type', this.options.type); + params.set('scene', this.options.scene); + + const resp = await fetch(url + '?' + params.toString(), { + method: 'POST', + headers: { 'X-Site-Key': this.options.siteKey } + }); + const result = await resp.json(); + + if (result.code !== 200) { + this.statusBar.textContent = result.msg || '加载失败'; + if (this.options.onFail) this.options.onFail(result); + return; + } + + this.captchaData = result.data; + console.log('[ROTATE-INIT] degree=' + result.data.degree + ' type=' + result.data.type + ' tplW=' + result.data.templateImageWidth + ' tplH=' + result.data.templateImageHeight); + this._renderCaptcha(); + this.statusBar.textContent = '请完成验证'; + } catch (e) { + this.statusBar.textContent = '网络错误'; + if (this.options.onFail) this.options.onFail({ code: 10000, msg: e.message }); + } + } + + _isClickType(type) { + return type === 'WORD_IMAGE_CLICK' || type === 'ICON_CLICK' || type === 'WORD_ORDER_CLICK'; + } + + _renderCaptcha() { + this.captchaContent.innerHTML = ''; + const data = this.captchaData; + if (!data) return; + const isRotate = data.type === 'ROTATE'; + const isConcat = data.type === 'CONCAT'; + const isCurveDraw = data.type === 'CURVE_DRAW'; + const isClick = this._isClickType(data.type); + + if (isClick) { + this._renderClickCaptcha(); + return; + } + + if (isCurveDraw) { + this._renderDrawCaptcha(); + return; + } + + const bgImg = document.createElement('img'); + bgImg.src = data.backgroundImage; + bgImg.style.cssText = 'width:100%;display:block;border-radius:4px;'; + + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'position:relative;border-radius:4px;'; + wrapper.appendChild(bgImg); + + let tplImg = null; + let concatTopLayer = null; + let concatRandomY = 0; + if (isConcat) { + concatRandomY = (data.data && data.data.randomY) ? data.data.randomY : Math.round(data.backgroundImageHeight / 2); + wrapper.innerHTML = ''; + wrapper.style.cssText = 'position:relative;border-radius:4px;overflow:hidden;width:100%;height:180px;'; + const bgLayer = document.createElement('div'); + bgLayer.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;z-index:1;background-image:url(' + data.backgroundImage + ');background-size:100% 180px;background-position:0 0;background-repeat:no-repeat;'; + wrapper.appendChild(bgLayer); + const overlayHeight = ((data.backgroundImageHeight - concatRandomY) / data.backgroundImageHeight) * 180; + concatTopLayer = document.createElement('div'); + concatTopLayer.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:' + overlayHeight + 'px;z-index:2;overflow:hidden;background-image:url(' + data.backgroundImage + ');background-size:100% 180px;background-position:0 0;background-repeat:no-repeat;'; + wrapper.appendChild(concatTopLayer); + } else if (data.templateImage) { + tplImg = document.createElement('img'); + tplImg.src = data.templateImage; + if (isRotate) { + const holeLeft = ((data.backgroundImageWidth - data.templateImageWidth) / 2 / data.backgroundImageWidth * 100); + const holeTop = ((data.backgroundImageHeight - data.templateImageHeight) / 2 / data.backgroundImageHeight * 100); + tplImg.style.cssText = 'position:absolute;left:' + holeLeft + '%;top:' + holeTop + '%;width:' + (data.templateImageWidth / data.backgroundImageWidth * 100) + '%;pointer-events:none;transform:rotate(0deg);transform-origin:center center;z-index:1;'; + } else { + tplImg.style.cssText = 'position:absolute;top:0;left:0;height:100%;pointer-events:none;transition:none;'; + } + wrapper.appendChild(tplImg); + } + + this.captchaContent.appendChild(wrapper); + + const sliderBar = document.createElement('div'); + const barBg = this.isDark ? '#2a2a4a' : '#f0f0f0'; + sliderBar.style.cssText = 'position:relative;height:40px;width:100%;background:' + barBg + ';border-radius:4px;margin-top:8px;overflow:hidden;'; + + const sliderTrack = document.createElement('div'); + sliderTrack.style.cssText = 'position:absolute;top:0;left:0;height:100%;width:0;background:linear-gradient(90deg,#52c41a,#73d13d);border-radius:4px;transition:none;'; + + const sliderBtnBg = this.isDark ? '#1a1a2e' : '#fff'; + const sliderBtn = document.createElement('div'); + sliderBtn.style.cssText = 'position:absolute;top:2px;left:0;width:36px;height:36px;background:' + sliderBtnBg + ';border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,0.15);cursor:grab;display:flex;align-items:center;justify-content:center;font-size:16px;color:#999;transition:none;'; + sliderBtn.textContent = isRotate ? '↻' : '→'; + + sliderBar.appendChild(sliderTrack); + sliderBar.appendChild(sliderBtn); + this.captchaContent.appendChild(sliderBar); + + this.startX = 0; + this.currentX = 0; + this.trackList = []; + this.startTime = Date.now(); + + const getBarWidth = () => sliderBar.offsetWidth; + + const onMove = (e) => { + if (!this.isDragging) return; + const barWidth = getBarWidth(); + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const dx = clientX - this.startX; + const maxX = barWidth - 36; + this.currentX = Math.max(0, Math.min(dx, maxX)); + + sliderBtn.style.left = this.currentX + 'px'; + sliderTrack.style.width = (this.currentX + 18) + 'px'; + + const ratio = this.currentX / barWidth; + let scaledX; + if (isRotate && tplImg) { + const bgW = this.captchaData.backgroundImageWidth; + const cssDegree = (this.currentX / barWidth) * 360; + tplImg.style.transform = 'rotate(' + cssDegree + 'deg)'; + scaledX = Math.round(ratio * bgW); + } else if (isConcat && concatTopLayer) { + const moveX = ratio * wrapper.offsetWidth; + concatTopLayer.style.backgroundPositionX = moveX + 'px'; + scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth); + } else if (tplImg) { + tplImg.style.left = (ratio * 100) + '%'; + scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth); + } else { + scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth); + } + this.trackList.push({ + x: scaledX, + y: 0, + t: Date.now() - this.startTime, + type: 'MOVE' + }); + }; + + const onUp = async () => { + if (!this.isDragging) return; + this.isDragging = false; + sliderBtn.style.cursor = 'grab'; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onUp); + + const barWidth = getBarWidth(); + const ratio = this.currentX / barWidth; + const scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth); + this.trackList.push({ + x: scaledX, + y: 0, + t: Date.now() - this.startTime, + type: 'UP' + }); + + this.statusBar.textContent = '验证中...'; + this.statusBar.style.color = '#999'; + + await this._verify(); + }; + + sliderBtn.addEventListener('mousedown', (e) => { + this.isDragging = true; + this.startX = e.clientX - this.currentX; + this.trackList = [{ x: 0, y: 0, t: 0, type: 'DOWN' }]; + this.startTime = Date.now(); + sliderBtn.style.cursor = 'grabbing'; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + + sliderBtn.addEventListener('touchstart', (e) => { + this.isDragging = true; + this.startX = e.touches[0].clientX - this.currentX; + this.trackList = [{ x: 0, y: 0, t: 0, type: 'DOWN' }]; + this.startTime = Date.now(); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onUp); + }); + } + + _renderClickCaptcha() { + const data = this.captchaData; + const isWordOrder = data.type === 'WORD_ORDER_CLICK'; + const tipText = isWordOrder ? '请依次点击' : '请依次点击图中文字'; + + const tipBar = document.createElement('div'); + tipBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;height:40px;margin-bottom:8px;'; + + const tipLabel = document.createElement('span'); + tipLabel.style.cssText = 'font-size:14px;color:' + (this.isDark ? '#e0e0e0' : '#333') + ';'; + tipLabel.textContent = tipText; + + tipBar.appendChild(tipLabel); + + if (data.templateImage) { + const tipImg = document.createElement('img'); + tipImg.src = data.templateImage; + tipImg.style.cssText = 'height:35px;max-width:200px;'; + tipBar.appendChild(tipImg); + } + + this.captchaContent.appendChild(tipBar); + + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'position:relative;border-radius:4px;overflow:hidden;cursor:crosshair;'; + + const bgImg = document.createElement('img'); + bgImg.src = data.backgroundImage; + bgImg.style.cssText = 'width:100%;display:block;border-radius:4px;'; + wrapper.appendChild(bgImg); + + const clickMask = document.createElement('div'); + clickMask.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;'; + wrapper.appendChild(clickMask); + + this.captchaContent.appendChild(wrapper); + + const confirmBtn = document.createElement('div'); + confirmBtn.style.cssText = 'width:100%;height:35px;border-radius:4px;background:linear-gradient(173deg,#e8a838 0%,#f0c060 100%);font-size:15px;text-align:center;line-height:35px;color:#fff;margin-top:8px;cursor:pointer;'; + confirmBtn.textContent = '确认'; + this.captchaContent.appendChild(confirmBtn); + + this.trackList = []; + this.startTime = Date.now(); + this.clickCount = 0; + this.clickMarkers = []; + + clickMask.addEventListener('click', (e) => { + if (e.target.className === 'tacaptcha-click-marker') return; + const rect = bgImg.getBoundingClientRect(); + const clickX = Math.round(e.clientX - rect.left); + const clickY = Math.round(e.clientY - rect.top); + const displayW = rect.width; + const displayH = rect.height; + const scaledX = Math.round((clickX / displayW) * data.backgroundImageWidth); + const scaledY = Math.round((clickY / displayH) * data.backgroundImageHeight); + + this.clickCount++; + if (this.clickCount === 1) { + this.startTime = Date.now(); + } + + this.trackList.push({ + x: scaledX, + y: scaledY, + t: Date.now() - this.startTime, + type: 'CLICK' + }); + + const marker = document.createElement('span'); + marker.className = 'tacaptcha-click-marker'; + marker.textContent = this.clickCount; + const left = clickX - 11; + const top = clickY - 11; + marker.style.cssText = 'position:absolute;left:' + left + 'px;top:' + top + 'px;border-radius:50%;background:#409eff;width:22px;height:22px;text-align:center;line-height:22px;color:#fff;border:2px solid #fff;box-sizing:content-box;font-size:12px;pointer-events:none;z-index:2;'; + clickMask.appendChild(marker); + this.clickMarkers.push(marker); + }); + + confirmBtn.addEventListener('click', async () => { + if (this.clickCount === 0) return; + this.statusBar.textContent = '验证中...'; + this.statusBar.style.color = '#999'; + await this._verify(); + }); + } + + _renderDrawCaptcha() { + const data = this.captchaData; + + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'position:relative;border-radius:4px;overflow:hidden;'; + + const bgImg = document.createElement('img'); + bgImg.src = data.backgroundImage; + bgImg.style.cssText = 'width:100%;display:block;border-radius:4px;'; + wrapper.appendChild(bgImg); + + const canvas = document.createElement('canvas'); + canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;touch-action:none;cursor:crosshair;'; + wrapper.appendChild(canvas); + + const tip = document.createElement('div'); + tip.textContent = '请在图上绘制曲线'; + tip.style.cssText = 'position:absolute;top:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.6);color:#fff;padding:4px 12px;border-radius:4px;font-size:13px;pointer-events:none;z-index:2;'; + wrapper.appendChild(tip); + + this.captchaContent.appendChild(wrapper); + + const confirmBtn = document.createElement('div'); + confirmBtn.style.cssText = 'width:100%;height:35px;border-radius:4px;background:linear-gradient(173deg,#e8a838 0%,#f0c060 100%);font-size:15px;text-align:center;line-height:35px;color:#fff;margin-top:8px;cursor:pointer;'; + confirmBtn.textContent = '确认'; + this.captchaContent.appendChild(confirmBtn); + + this.trackList = []; + this.startTime = Date.now(); + this.isDrawing = false; + + let ctx = null; + + const getPoint = (e) => { + const rect = bgImg.getBoundingClientRect(); + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const clientY = e.touches ? e.touches[0].clientY : e.clientY; + const x = clientX - rect.left; + const y = clientY - rect.top; + const scaledX = Math.round((x / rect.width) * data.backgroundImageWidth); + const scaledY = Math.round((y / rect.height) * data.backgroundImageHeight); + return { x, y, scaledX, scaledY }; + }; + + const startDraw = (e) => { + e.preventDefault(); + this.isDrawing = true; + const p = getPoint(e); + this.trackList.push({ x: p.scaledX, y: p.scaledY, t: 0, type: 'DOWN' }); + + const rect = wrapper.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + ctx = canvas.getContext('2d'); + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineWidth = 3; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.strokeStyle = 'rgba(64, 158, 255, 0.9)'; + tip.style.display = 'none'; + }; + + const moveDraw = (e) => { + if (!this.isDrawing) return; + e.preventDefault(); + const p = getPoint(e); + this.trackList.push({ x: p.scaledX, y: p.scaledY, t: Date.now() - this.startTime, type: 'MOVE' }); + if (ctx) { + ctx.lineTo(p.x, p.y); + ctx.stroke(); + } + }; + + const endDraw = (e) => { + if (!this.isDrawing) return; + this.isDrawing = false; + let clientX, clientY; + if (e.changedTouches && e.changedTouches.length > 0) { + clientX = e.changedTouches[0].clientX; + clientY = e.changedTouches[0].clientY; + } else { + clientX = e.clientX; + clientY = e.clientY; + } + const rect = bgImg.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + const scaledX = Math.round((x / rect.width) * data.backgroundImageWidth); + const scaledY = Math.round((y / rect.height) * data.backgroundImageHeight); + this.trackList.push({ x: scaledX, y: scaledY, t: Date.now() - this.startTime, type: 'UP' }); + }; + + canvas.addEventListener('mousedown', startDraw); + canvas.addEventListener('mousemove', moveDraw); + canvas.addEventListener('mouseup', endDraw); + canvas.addEventListener('touchstart', startDraw, { passive: false }); + canvas.addEventListener('touchmove', moveDraw, { passive: false }); + canvas.addEventListener('touchend', endDraw); + + confirmBtn.addEventListener('click', async () => { + if (this.trackList.length === 0) return; + this.statusBar.textContent = '验证中...'; + this.statusBar.style.color = '#999'; + await this._verify(); + }); + } + + async _verify() { + if (!this.captchaData) return; + this.statusBar.textContent = '验证中...'; + + try { + const trackData = { + bgImageWidth: this.captchaData.backgroundImageWidth, + bgImageHeight: this.captchaData.backgroundImageHeight, + templateImageWidth: this.captchaData.templateImageWidth, + templateImageHeight: this.captchaData.templateImageHeight, + startTime: this.startTime, + stopTime: Date.now(), + trackList: this.trackList + }; + + const resp = await fetch(`${this.options.serverUrl}/challenge/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Site-Key': this.options.siteKey + }, + body: JSON.stringify({ + id: this.captchaData.id, + data: trackData + }) + }); + const result = await resp.json(); + + if (result.code === 200 && result.data && result.data.verifyToken) { + this.verifyToken = result.data.verifyToken; + this.statusBar.textContent = '验证成功'; + this.statusBar.style.color = '#52c41a'; + if (this.options.onSuccess) { + this.options.onSuccess({ + code: 200, + msg: '操作成功', + data: { verifyToken: this.verifyToken } + }); + } + setTimeout(() => { + this._closeCaptcha(); + this._setShieldState('success'); + }, 400); + } else { + this._setShieldState('fail'); + this.statusBar.textContent = '验证失败,请重试'; + this.statusBar.style.color = '#ff4d4f'; + if (this.options.onFail) this.options.onFail(result); + setTimeout(() => this._loadCaptcha(), 1500); + } + } catch (e) { + this.statusBar.textContent = '网络错误'; + if (this.options.onFail) this.options.onFail({ code: 10000, msg: e.message }); + } + } + + async show() { + await this._openCaptcha(); + } + + destroy() { + if (this.modal && this.modal.parentNode) { + this.modal.parentNode.removeChild(this.modal); + } + if (this.options.elId) { + INSTANCES.delete(this.options.elId); + } + } + } + + const TACaptcha = { + async init(options) { + const elId = options.elId || ('_tac_' + Date.now()); + if (INSTANCES.has(elId)) { + INSTANCES.get(elId).destroy(); + } + const instance = new TACaptchaInstance(options); + INSTANCES.set(elId, instance); + return instance; + }, + + refresh(elId) { + const inst = INSTANCES.get(elId); + if (inst) inst._loadCaptcha(); + }, + + show(elId) { + const inst = INSTANCES.get(elId); + if (inst) return inst.show(); + return Promise.reject(new Error('Instance not found')); + } + }; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = TACaptcha; + } else { + global.TACaptcha = TACaptcha; + } +})(typeof window !== 'undefined' ? window : this); diff --git a/tianai-captcha-platform-ui/src/App.vue b/tianai-captcha-platform-ui/src/App.vue new file mode 100644 index 0000000..46c7558 --- /dev/null +++ b/tianai-captcha-platform-ui/src/App.vue @@ -0,0 +1,45 @@ + + + diff --git a/tianai-captcha-platform-ui/src/main.js b/tianai-captcha-platform-ui/src/main.js new file mode 100644 index 0000000..963bf61 --- /dev/null +++ b/tianai-captcha-platform-ui/src/main.js @@ -0,0 +1,21 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import App from './App.vue' +import Dashboard from './views/Dashboard.vue' +import Sites from './views/Sites.vue' +import Stats from './views/Stats.vue' +import CaptchaTest from './views/CaptchaTest.vue' + +const routes = [ + { path: '/', component: Dashboard }, + { path: '/sites', component: Sites }, + { path: '/stats', component: Stats }, + { path: '/test', component: CaptchaTest }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +createApp(App).use(router).mount('#app') diff --git a/tianai-captcha-platform-ui/src/views/CaptchaTest.vue b/tianai-captcha-platform-ui/src/views/CaptchaTest.vue new file mode 100644 index 0000000..4fb559f --- /dev/null +++ b/tianai-captcha-platform-ui/src/views/CaptchaTest.vue @@ -0,0 +1,240 @@ + + + diff --git a/tianai-captcha-platform-ui/src/views/Dashboard.vue b/tianai-captcha-platform-ui/src/views/Dashboard.vue new file mode 100644 index 0000000..2ce8be9 --- /dev/null +++ b/tianai-captcha-platform-ui/src/views/Dashboard.vue @@ -0,0 +1,49 @@ + + + diff --git a/tianai-captcha-platform-ui/src/views/Sites.vue b/tianai-captcha-platform-ui/src/views/Sites.vue new file mode 100644 index 0000000..4f8b752 --- /dev/null +++ b/tianai-captcha-platform-ui/src/views/Sites.vue @@ -0,0 +1,152 @@ + + + diff --git a/tianai-captcha-platform-ui/src/views/Stats.vue b/tianai-captcha-platform-ui/src/views/Stats.vue new file mode 100644 index 0000000..75043c2 --- /dev/null +++ b/tianai-captcha-platform-ui/src/views/Stats.vue @@ -0,0 +1,67 @@ + + + diff --git a/tianai-captcha-platform-ui/vite.config.js b/tianai-captcha-platform-ui/vite.config.js new file mode 100644 index 0000000..e71a906 --- /dev/null +++ b/tianai-captcha-platform-ui/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5174, + proxy: { + '/api': { + target: 'http://localhost:18200', + changeOrigin: true + } + } + } +}) diff --git a/tianai-captcha-platform/Dockerfile b/tianai-captcha-platform/Dockerfile new file mode 100644 index 0000000..4589720 --- /dev/null +++ b/tianai-captcha-platform/Dockerfile @@ -0,0 +1,11 @@ +FROM eclipse-temurin:21-jre-alpine + +RUN apk add --no-cache fontconfig ttf-dejavu + +WORKDIR /app + +COPY tianai-captcha-platform-2.0.0-SNAPSHOT.jar /app/captcha-platform.jar + +EXPOSE 18200 + +ENTRYPOINT ["java", "-jar", "/app/captcha-platform.jar"] diff --git a/tianai-captcha-platform/pom.xml b/tianai-captcha-platform/pom.xml new file mode 100644 index 0000000..4c5348e --- /dev/null +++ b/tianai-captcha-platform/pom.xml @@ -0,0 +1,80 @@ + + 4.0.0 + + cloud.tianai.captcha + tianai-captcha-parent + ${revision} + ../pom.xml + + tianai-captcha-platform + tianai-captcha-platform + 验证码服务平台后端 + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.postgresql + postgresql + runtime + + + cloud.tianai.captcha + tianai-captcha-springboot-starter + ${revision} + + + com.google.code.gson + gson + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + 21 + + + + + diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/CaptchaPlatformApplication.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/CaptchaPlatformApplication.java new file mode 100644 index 0000000..fcd26d8 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/CaptchaPlatformApplication.java @@ -0,0 +1,15 @@ +package cloud.tianai.captcha.platform; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication(scanBasePackages = {"cloud.tianai.captcha"}) +@EnableJpaRepositories(basePackages = "cloud.tianai.captcha.platform.mapper") +@EnableScheduling +public class CaptchaPlatformApplication { + public static void main(String[] args) { + SpringApplication.run(CaptchaPlatformApplication.class, args); + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/config/CaptchaPlatformConfig.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/config/CaptchaPlatformConfig.java new file mode 100644 index 0000000..4d968d9 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/config/CaptchaPlatformConfig.java @@ -0,0 +1,105 @@ +package cloud.tianai.captcha.platform.config; + +import cloud.tianai.captcha.application.ImageCaptchaApplication; +import cloud.tianai.captcha.cache.CacheStore; +import cloud.tianai.captcha.cache.impl.LocalCacheStore; +import cloud.tianai.captcha.common.constant.CaptchaTypeConstant; +import cloud.tianai.captcha.resource.CrudResourceStore; +import cloud.tianai.captcha.resource.ResourceStore; +import cloud.tianai.captcha.resource.common.model.dto.Resource; +import cloud.tianai.captcha.resource.common.model.dto.ResourceMap; +import cloud.tianai.captcha.risk.IpBlacklist; +import cloud.tianai.captcha.risk.RiskEngine; +import cloud.tianai.captcha.site.TokenService; +import cloud.tianai.captcha.validator.ImageCaptchaValidator; +import cloud.tianai.captcha.validator.impl.EnhancedTrackValidator; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +import static cloud.tianai.captcha.common.constant.CommonConstant.DEFAULT_TAG; +import static cloud.tianai.captcha.generator.impl.StandardSliderImageCaptchaGenerator.TEMPLATE_ACTIVE_IMAGE_NAME; +import static cloud.tianai.captcha.generator.impl.StandardSliderImageCaptchaGenerator.TEMPLATE_FIXED_IMAGE_NAME; + +@Configuration +public class CaptchaPlatformConfig { + + private static final String TP = "META-INF/cut-image/template"; + + @Bean + public CacheStore cacheStore() { + return new LocalCacheStore(); + } + + @Bean + public RiskEngine riskEngine() { + return new RiskEngine(); + } + + @Bean + public IpBlacklist ipBlacklist() { + return new IpBlacklist(); + } + + @Bean + public TokenService tokenService(CacheStore cacheStore) { + return new TokenService(cacheStore); + } + + @Bean + public ImageCaptchaValidator captchaValidator() { + return new EnhancedTrackValidator(); + } + + @Bean + public CommandLineRunner initCaptchaResources(ResourceStore resourceStore) { + return args -> { + if (resourceStore instanceof CrudResourceStore crud) { + List bgTypes = List.of( + CaptchaTypeConstant.SLIDER, + CaptchaTypeConstant.SLIDER_V2, + CaptchaTypeConstant.ROTATE, + CaptchaTypeConstant.CONCAT, + CaptchaTypeConstant.WORD_IMAGE_CLICK, + CaptchaTypeConstant.ICON_CLICK, + CaptchaTypeConstant.WORD_ORDER_CLICK, + CaptchaTypeConstant.CURVE_SLIDER, + CaptchaTypeConstant.CURVE_SLIDER_V2, + CaptchaTypeConstant.CURVE_SLIDER_V3, + CaptchaTypeConstant.ANGLE, + CaptchaTypeConstant.SCRATCH, + CaptchaTypeConstant.JIGSAW, + CaptchaTypeConstant.CURVE_DRAW + ); + for (String type : bgTypes) { + crud.addResource(type, new Resource("classpath", "META-INF/cut-image/resource/1.jpg")); + } + + List sliderAliasTypes = List.of( + CaptchaTypeConstant.SLIDER_V2, + CaptchaTypeConstant.CONCAT, + CaptchaTypeConstant.CURVE_SLIDER, + CaptchaTypeConstant.CURVE_SLIDER_V2, + CaptchaTypeConstant.CURVE_SLIDER_V3, + CaptchaTypeConstant.ANGLE, + CaptchaTypeConstant.SCRATCH, + CaptchaTypeConstant.JIGSAW, + CaptchaTypeConstant.CURVE_DRAW + ); + for (String type : sliderAliasTypes) { + ResourceMap t1 = new ResourceMap(DEFAULT_TAG, 4); + t1.put(TEMPLATE_ACTIVE_IMAGE_NAME, new Resource("classpath", TP + "/slider_1/active.png")); + t1.put(TEMPLATE_FIXED_IMAGE_NAME, new Resource("classpath", TP + "/slider_1/fixed.png")); + crud.addTemplate(type, t1); + + ResourceMap t2 = new ResourceMap(DEFAULT_TAG, 4); + t2.put(TEMPLATE_ACTIVE_IMAGE_NAME, new Resource("classpath", TP + "/slider_2/active.png")); + t2.put(TEMPLATE_FIXED_IMAGE_NAME, new Resource("classpath", TP + "/slider_2/fixed.png")); + crud.addTemplate(type, t2); + } + } + }; + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/config/CorsConfig.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/config/CorsConfig.java new file mode 100644 index 0000000..2e2999f --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/config/CorsConfig.java @@ -0,0 +1,19 @@ +package cloud.tianai.captcha.platform.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/CaptchaApiController.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/CaptchaApiController.java new file mode 100644 index 0000000..c833f3b --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/CaptchaApiController.java @@ -0,0 +1,65 @@ +package cloud.tianai.captcha.platform.controller; + +import cloud.tianai.captcha.application.ImageCaptchaApplication; +import cloud.tianai.captcha.application.vo.ImageCaptchaVO; +import cloud.tianai.captcha.common.response.ApiResponse; +import cloud.tianai.captcha.platform.service.CaptchaPlatformService; +import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack; +import cloud.tianai.captcha.validator.common.model.dto.MatchParam; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api") +public class CaptchaApiController { + + private final CaptchaPlatformService platformService; + + public CaptchaApiController(CaptchaPlatformService platformService) { + this.platformService = platformService; + } + + @PostMapping("/challenge/generate") + public ApiResponse generate( + @RequestHeader(value = "X-Site-Key", required = false) String siteKey, + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "scene", required = false, defaultValue = "default") String scene, + @RequestHeader(value = "X-Real-IP", required = false) String ip, + @RequestHeader(value = "X-Forwarded-For", required = false) String forwardedFor) { + String clientIp = resolveIp(ip, forwardedFor); + return platformService.generateCaptcha(siteKey, type, scene, clientIp); + } + + @PostMapping("/challenge/verify") + public ApiResponse verify( + @RequestHeader(value = "X-Site-Key", required = false) String siteKey, + @RequestBody Map body) { + String captchaId = (String) body.get("id"); + Object trackData = body.get("data"); + String ip = (String) body.getOrDefault("ip", "unknown"); + return platformService.verifyCaptcha(siteKey, captchaId, trackData, ip); + } + + @PostMapping("/challenge/secondary-verify") + public ApiResponse secondaryVerify( + @RequestBody Map body) { + String siteKey = (String) body.get("siteKey"); + String secretKey = (String) body.get("secretKey"); + String verifyToken = (String) body.get("verifyToken"); + return platformService.secondaryVerify(siteKey, secretKey, verifyToken); + } + + @GetMapping("/challenge/public-key") + public ApiResponse getPublicKey( + @RequestHeader(value = "X-Site-Key", required = false) String siteKey) { + return platformService.getPublicKey(siteKey); + } + + private String resolveIp(String ip, String forwardedFor) { + if (forwardedFor != null && !forwardedFor.isEmpty()) { + return forwardedFor.split(",")[0].trim(); + } + return ip != null ? ip : "unknown"; + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/SiteAdminController.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/SiteAdminController.java new file mode 100644 index 0000000..7055075 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/SiteAdminController.java @@ -0,0 +1,98 @@ +package cloud.tianai.captcha.platform.controller; + +import cloud.tianai.captcha.common.response.ApiResponse; +import cloud.tianai.captcha.platform.entity.CaptchaSite; +import cloud.tianai.captcha.platform.service.CaptchaPlatformService; +import cloud.tianai.captcha.platform.service.TrackLearningService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.Set; + +@RestController +@RequestMapping("/api/admin") +public class SiteAdminController { + + private final CaptchaPlatformService platformService; + private final TrackLearningService trackLearningService; + + public SiteAdminController(CaptchaPlatformService platformService, TrackLearningService trackLearningService) { + this.platformService = platformService; + this.trackLearningService = trackLearningService; + } + + @PostMapping("/sites") + public ApiResponse createSite(@RequestBody Map body) { + String name = (String) body.get("name"); + String domain = (String) body.get("domain"); + @SuppressWarnings("unchecked") + Set captchaTypes = body.get("captchaTypes") != null + ? Set.copyOf((java.util.List) body.get("captchaTypes")) + : null; + if (captchaTypes == null && body.get("allowedTypes") != null) { + captchaTypes = Set.copyOf((java.util.List) body.get("allowedTypes")); + } + CaptchaSite site = platformService.createSite(name, domain, captchaTypes); + return ApiResponse.ofSuccess(site); + } + + @GetMapping("/sites") + public ApiResponse> listSites( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ApiResponse.ofSuccess(platformService.listSites(PageRequest.of(page, size))); + } + + @GetMapping("/sites/{siteId}") + public ApiResponse getSite(@PathVariable Integer siteId) { + return ApiResponse.ofSuccess(platformService.getSite(siteId)); + } + + @PutMapping("/sites/{siteId}") + public ApiResponse updateSite(@PathVariable Integer siteId, @RequestBody CaptchaSite site) { + site.setId(siteId); + return ApiResponse.ofSuccess(platformService.updateSite(site)); + } + + @DeleteMapping("/sites/{siteId}") + public ApiResponse deleteSite(@PathVariable Integer siteId) { + platformService.deleteSite(siteId); + return ApiResponse.ofSuccess(); + } + + @GetMapping("/stats") + public ApiResponse getStats( + @RequestParam(required = false) Integer siteId, + @RequestParam(defaultValue = "7") int days) { + return ApiResponse.ofSuccess(platformService.getStats(siteId, days)); + } + + @PostMapping("/ip-blacklist") + public ApiResponse banIp(@RequestBody Map body) { + String ip = (String) body.get("ip"); + String reason = (String) body.get("reason"); + long durationMs = body.get("durationMs") != null + ? ((Number) body.get("durationMs")).longValue() + : 3600000L; + platformService.banIp(ip, reason, durationMs); + return ApiResponse.ofSuccess(); + } + + @DeleteMapping("/ip-blacklist/{ip}") + public ApiResponse unbanIp(@PathVariable String ip) { + platformService.unbanIp(ip); + return ApiResponse.ofSuccess(); + } + + @GetMapping("/ml/status") + public ApiResponse getMlStatus() { + return ApiResponse.ofSuccess(trackLearningService.getStatus()); + } + + @PostMapping("/ml/train") + public ApiResponse triggerTraining() { + return ApiResponse.ofSuccess(trackLearningService.trainIfNeeded()); + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/CaptchaLog.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/CaptchaLog.java new file mode 100644 index 0000000..398bac5 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/CaptchaLog.java @@ -0,0 +1,91 @@ +package cloud.tianai.captcha.platform.entity; + +import jakarta.persistence.*; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "verification_logs") +public class CaptchaLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "site_id") + private Integer siteId; + + @Column(name = "site_key") + private java.util.UUID siteKey; + + @Column(name = "captcha_type", length = 32) + private String captchaType; + + @Column(length = 64) + private String scene = "default"; + + @Column(length = 45) + private String ip; + + @Column(name = "is_pass") + private Boolean isPass; + + @Column(name = "behavior_score") + private Double behaviorScore; + + @Column(name = "risk_level", length = 16) + private String riskLevel; + + @Column(name = "cost_time") + private Integer costTime; + + @Column(name = "captcha_id", length = 128) + private String captchaId; + + @Column(name = "user_agent", columnDefinition = "TEXT") + private String userAgent; + + @Column(name = "track_score") + private Double trackScore; + + @Column(name = "created_at") + private OffsetDateTime createdAt; + + @PrePersist + protected void onCreate() { createdAt = OffsetDateTime.now(); } + + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + public Integer getSiteId() { return siteId; } + public void setSiteId(Integer v) { this.siteId = v; } + public java.util.UUID getSiteKey() { return siteKey; } + public void setSiteKey(java.util.UUID v) { this.siteKey = v; } + public String getCaptchaType() { return captchaType; } + public void setCaptchaType(String v) { this.captchaType = v; } + public String getScene() { return scene; } + public void setScene(String v) { this.scene = v; } + public String getIp() { return ip; } + public void setIp(String v) { this.ip = v; } + public Boolean getIsPass() { return isPass; } + public void setIsPass(Boolean v) { this.isPass = v; } + public Double getBehaviorScore() { return behaviorScore; } + public void setBehaviorScore(Double v) { this.behaviorScore = v; } + public String getRiskLevel() { return riskLevel; } + public void setRiskLevel(String v) { this.riskLevel = v; } + public Integer getCostTime() { return costTime; } + public void setCostTime(Integer v) { this.costTime = v; } + public String getCaptchaId() { return captchaId; } + public void setCaptchaId(String v) { this.captchaId = v; } + public String getUserAgent() { return userAgent; } + public void setUserAgent(String v) { this.userAgent = v; } + public Double getTrackScore() { return trackScore; } + public void setTrackScore(Double v) { this.trackScore = v; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime v) { this.createdAt = v; } + + public String getResult() { return isPass != null && isPass ? "SUCCESS" : "FAIL"; } + public void setResult(String v) { this.isPass = "SUCCESS".equals(v); } + public String getType() { return captchaType; } + public void setType(String v) { this.captchaType = v; } + public Long getDurationMs() { return costTime != null ? costTime.longValue() : null; } + public void setDurationMs(Long v) { this.costTime = v != null ? v.intValue() : null; } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/CaptchaSite.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/CaptchaSite.java new file mode 100644 index 0000000..db8deda --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/CaptchaSite.java @@ -0,0 +1,158 @@ +package cloud.tianai.captcha.platform.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.Set; + +@Entity +@Table(name = "sites") +public class CaptchaSite { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "user_id") + private Integer userId; + + @Column(length = 64, nullable = false) + private String name; + + @Column(length = 256) + private String domain; + + @Column(length = 512) + private String favicon; + + @Column(length = 512) + private String logo; + + @Column(name = "site_key", unique = true) + private java.util.UUID siteKey; + + @Column(name = "secret_key", unique = true) + private java.util.UUID secretKey; + + @Column(name = "verify_level", length = 16) + private String verifyLevel = "MEDIUM"; + + @Column + private Integer qps = 10; + + @Column(name = "daily_limit") + private Integer dailyLimit = 500; + + @Column(name = "captcha_types", columnDefinition = "TEXT[]") + private Set captchaTypes; + + @Column(name = "plan_id") + private Integer planId; + + @Column(name = "plan_expire_at") + private OffsetDateTime planExpireAt; + + @Column(name = "is_enabled") + private Boolean isEnabled = true; + + @Column(name = "rsa_public_key", columnDefinition = "TEXT") + private String rsaPublicKey; + + @Column(name = "rsa_private_key", columnDefinition = "TEXT") + private String rsaPrivateKey; + + @Column(name = "aes_key", length = 256) + private String aesKey; + + @Column(name = "signing_key", length = 256) + private String signingKey; + + @Column(name = "track_validation_enabled") + private Boolean trackValidationEnabled = true; + + @Column(name = "track_human_threshold") + private Double trackHumanThreshold = 0.5; + + @Column(name = "obfuscation_enabled") + private Boolean obfuscationEnabled = true; + + @Column(name = "encryption_enabled") + private Boolean encryptionEnabled = true; + + @Column(name = "created_at") + private OffsetDateTime createdAt; + + @Column(name = "updated_at") + private OffsetDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = OffsetDateTime.now(); + updatedAt = OffsetDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = OffsetDateTime.now(); + } + + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + public Integer getUserId() { return userId; } + public void setUserId(Integer userId) { this.userId = userId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDomain() { return domain; } + public void setDomain(String domain) { this.domain = domain; } + public String getFavicon() { return favicon; } + public void setFavicon(String favicon) { this.favicon = favicon; } + public String getLogo() { return logo; } + public void setLogo(String logo) { this.logo = logo; } + public java.util.UUID getSiteKey() { return siteKey; } + public void setSiteKey(java.util.UUID siteKey) { this.siteKey = siteKey; } + public java.util.UUID getSecretKey() { return secretKey; } + public void setSecretKey(java.util.UUID secretKey) { this.secretKey = secretKey; } + public String getVerifyLevel() { return verifyLevel; } + public void setVerifyLevel(String verifyLevel) { this.verifyLevel = verifyLevel; } + public Integer getQps() { return qps; } + public void setQps(Integer qps) { this.qps = qps; } + public Integer getDailyLimit() { return dailyLimit; } + public void setDailyLimit(Integer dailyLimit) { this.dailyLimit = dailyLimit; } + public Set getCaptchaTypes() { return captchaTypes; } + public void setCaptchaTypes(Set captchaTypes) { this.captchaTypes = captchaTypes; } + public Integer getPlanId() { return planId; } + public void setPlanId(Integer planId) { this.planId = planId; } + public OffsetDateTime getPlanExpireAt() { return planExpireAt; } + public void setPlanExpireAt(OffsetDateTime planExpireAt) { this.planExpireAt = planExpireAt; } + public Boolean getIsEnabled() { return isEnabled; } + public void setIsEnabled(Boolean isEnabled) { this.isEnabled = isEnabled; } + public String getRsaPublicKey() { return rsaPublicKey; } + public void setRsaPublicKey(String v) { this.rsaPublicKey = v; } + public String getRsaPrivateKey() { return rsaPrivateKey; } + public void setRsaPrivateKey(String v) { this.rsaPrivateKey = v; } + public String getAesKey() { return aesKey; } + public void setAesKey(String v) { this.aesKey = v; } + public String getSigningKey() { return signingKey; } + public void setSigningKey(String v) { this.signingKey = v; } + public Boolean getTrackValidationEnabled() { return trackValidationEnabled; } + public void setTrackValidationEnabled(Boolean v) { this.trackValidationEnabled = v; } + public Double getTrackHumanThreshold() { return trackHumanThreshold; } + public void setTrackHumanThreshold(Double v) { this.trackHumanThreshold = v; } + public Boolean getObfuscationEnabled() { return obfuscationEnabled; } + public void setObfuscationEnabled(Boolean v) { this.obfuscationEnabled = v; } + public Boolean getEncryptionEnabled() { return encryptionEnabled; } + public void setEncryptionEnabled(Boolean v) { this.encryptionEnabled = v; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime v) { this.createdAt = v; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime v) { this.updatedAt = v; } + + public Boolean getEnabled() { return isEnabled; } + public void setEnabled(Boolean v) { this.isEnabled = v; } + public Set getAllowedTypes() { return captchaTypes; } + public void setAllowedTypes(Set v) { this.captchaTypes = v; } + public String getLevel() { return verifyLevel; } + public void setLevel(String v) { this.verifyLevel = v; } + public Integer getMaxQps() { return qps; } + public void setMaxQps(Integer v) { this.qps = v; } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/TrackSample.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/TrackSample.java new file mode 100644 index 0000000..ed3ccd8 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/TrackSample.java @@ -0,0 +1,239 @@ +package cloud.tianai.captcha.platform.entity; + +import jakarta.persistence.*; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "track_samples") +public class TrackSample { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "site_id") + private Integer siteId; + + @Column(name = "captcha_type", length = 32) + private String captchaType; + + @Column(name = "is_human") + private Boolean isHuman; + + @Column(name = "ml_score") + private Double mlScore; + + @Column(name = "basic_pass") + private Boolean basicPass; + + @Column(name = "total_points") + private Integer totalPoints; + + @Column(name = "total_duration") + private Long totalDuration; + + @Column(name = "displacement_x") + private Float displacementX; + + @Column(name = "displacement_y") + private Float displacementY; + + @Column(name = "displacement_x_ratio") + private Float displacementXRatio; + + @Column(name = "total_path_length") + private Double totalPathLength; + + @Column(name = "path_efficiency") + private Double pathEfficiency; + + @Column(name = "avg_speed") + private Float avgSpeed; + + @Column(name = "max_speed") + private Float maxSpeed; + + @Column(name = "min_speed") + private Float minSpeed; + + @Column(name = "speed_variance") + private Double speedVariance; + + @Column(name = "speed_std_dev") + private Double speedStdDev; + + @Column(name = "speed_skewness") + private Double speedSkewness; + + @Column(name = "avg_acceleration") + private Float avgAcceleration; + + @Column(name = "max_acceleration") + private Float maxAcceleration; + + @Column(name = "min_acceleration") + private Float minAcceleration; + + @Column(name = "acceleration_variance") + private Double accelerationVariance; + + @Column(name = "direction_changes") + private Integer directionChanges; + + @Column(name = "y_direction_changes") + private Integer yDirectionChanges; + + @Column(name = "pauses") + private Integer pauses; + + @Column(name = "start_offset") + private Double startOffset; + + @Column(name = "straightness") + private Double straightness; + + @Column(name = "x_uniformity") + private Double xUniformity; + + @Column(name = "y_uniformity") + private Double yUniformity; + + @Column(name = "avg_point_interval") + private Float avgPointInterval; + + @Column(name = "speed_phase_correlation") + private Double speedPhaseCorrelation; + + @Column(name = "max_jump_distance") + private Double maxJumpDistance; + + @Column(name = "overshoot_ratio") + private Double overshootRatio; + + @Column(name = "track_json", columnDefinition = "TEXT") + private String trackJson; + + @Column(length = 45) + private String ip; + + @Column(name = "created_at") + private OffsetDateTime createdAt; + + @PrePersist + protected void onCreate() { createdAt = OffsetDateTime.now(); } + + public static TrackSample fromFeatures(cloud.tianai.captcha.ml.TrackFeatures f, Boolean isHuman, Double mlScore, Boolean basicPass, String captchaType, Integer siteId, String trackJson, String ip) { + TrackSample s = new TrackSample(); + s.isHuman = isHuman; + s.mlScore = mlScore; + s.basicPass = basicPass; + s.captchaType = captchaType; + s.siteId = siteId; + s.trackJson = trackJson; + s.ip = ip; + s.totalPoints = f.totalPoints; + s.totalDuration = f.totalDuration; + s.displacementX = f.displacementX; + s.displacementY = f.displacementY; + s.displacementXRatio = f.displacementXRatio; + s.totalPathLength = f.totalPathLength; + s.pathEfficiency = f.pathEfficiency; + s.avgSpeed = f.avgSpeed; + s.maxSpeed = f.maxSpeed; + s.minSpeed = f.minSpeed; + s.speedVariance = f.speedVariance; + s.speedStdDev = f.speedStdDev; + s.speedSkewness = f.speedSkewness; + s.avgAcceleration = f.avgAcceleration; + s.maxAcceleration = f.maxAcceleration; + s.minAcceleration = f.minAcceleration; + s.accelerationVariance = f.accelerationVariance; + s.directionChanges = f.directionChanges; + s.yDirectionChanges = f.yDirectionChanges; + s.pauses = f.pauses; + s.startOffset = f.startOffset; + s.straightness = f.straightness; + s.xUniformity = f.xUniformity; + s.yUniformity = f.yUniformity; + s.avgPointInterval = f.avgPointInterval; + s.speedPhaseCorrelation = f.speedPhaseCorrelation; + s.maxJumpDistance = f.maxJumpDistance; + s.overshootRatio = f.overshootRatio; + return s; + } + + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + public Integer getSiteId() { return siteId; } + public void setSiteId(Integer siteId) { this.siteId = siteId; } + public String getCaptchaType() { return captchaType; } + public void setCaptchaType(String captchaType) { this.captchaType = captchaType; } + public Boolean getIsHuman() { return isHuman; } + public void setIsHuman(Boolean isHuman) { this.isHuman = isHuman; } + public Double getMlScore() { return mlScore; } + public void setMlScore(Double mlScore) { this.mlScore = mlScore; } + public Boolean getBasicPass() { return basicPass; } + public void setBasicPass(Boolean basicPass) { this.basicPass = basicPass; } + public Integer getTotalPoints() { return totalPoints; } + public void setTotalPoints(Integer totalPoints) { this.totalPoints = totalPoints; } + public Long getTotalDuration() { return totalDuration; } + public void setTotalDuration(Long totalDuration) { this.totalDuration = totalDuration; } + public Float getDisplacementX() { return displacementX; } + public void setDisplacementX(Float displacementX) { this.displacementX = displacementX; } + public Float getDisplacementY() { return displacementY; } + public void setDisplacementY(Float displacementY) { this.displacementY = displacementY; } + public Float getDisplacementXRatio() { return displacementXRatio; } + public void setDisplacementXRatio(Float displacementXRatio) { this.displacementXRatio = displacementXRatio; } + public Double getTotalPathLength() { return totalPathLength; } + public void setTotalPathLength(Double totalPathLength) { this.totalPathLength = totalPathLength; } + public Double getPathEfficiency() { return pathEfficiency; } + public void setPathEfficiency(Double pathEfficiency) { this.pathEfficiency = pathEfficiency; } + public Float getAvgSpeed() { return avgSpeed; } + public void setAvgSpeed(Float avgSpeed) { this.avgSpeed = avgSpeed; } + public Float getMaxSpeed() { return maxSpeed; } + public void setMaxSpeed(Float maxSpeed) { this.maxSpeed = maxSpeed; } + public Float getMinSpeed() { return minSpeed; } + public void setMinSpeed(Float minSpeed) { this.minSpeed = minSpeed; } + public Double getSpeedVariance() { return speedVariance; } + public void setSpeedVariance(Double speedVariance) { this.speedVariance = speedVariance; } + public Double getSpeedStdDev() { return speedStdDev; } + public void setSpeedStdDev(Double speedStdDev) { this.speedStdDev = speedStdDev; } + public Double getSpeedSkewness() { return speedSkewness; } + public void setSpeedSkewness(Double speedSkewness) { this.speedSkewness = speedSkewness; } + public Float getAvgAcceleration() { return avgAcceleration; } + public void setAvgAcceleration(Float avgAcceleration) { this.avgAcceleration = avgAcceleration; } + public Float getMaxAcceleration() { return maxAcceleration; } + public void setMaxAcceleration(Float maxAcceleration) { this.maxAcceleration = maxAcceleration; } + public Float getMinAcceleration() { return minAcceleration; } + public void setMinAcceleration(Float minAcceleration) { this.minAcceleration = minAcceleration; } + public Double getAccelerationVariance() { return accelerationVariance; } + public void setAccelerationVariance(Double accelerationVariance) { this.accelerationVariance = accelerationVariance; } + public Integer getDirectionChanges() { return directionChanges; } + public void setDirectionChanges(Integer directionChanges) { this.directionChanges = directionChanges; } + public Integer getYDirectionChanges() { return yDirectionChanges; } + public void setYDirectionChanges(Integer yDirectionChanges) { this.yDirectionChanges = yDirectionChanges; } + public Integer getPauses() { return pauses; } + public void setPauses(Integer pauses) { this.pauses = pauses; } + public Double getStartOffset() { return startOffset; } + public void setStartOffset(Double startOffset) { this.startOffset = startOffset; } + public Double getStraightness() { return straightness; } + public void setStraightness(Double straightness) { this.straightness = straightness; } + public Double getXUniformity() { return xUniformity; } + public void setXUniformity(Double xUniformity) { this.xUniformity = xUniformity; } + public Double getYUniformity() { return yUniformity; } + public void setYUniformity(Double yUniformity) { this.yUniformity = yUniformity; } + public Float getAvgPointInterval() { return avgPointInterval; } + public void setAvgPointInterval(Float avgPointInterval) { this.avgPointInterval = avgPointInterval; } + public Double getSpeedPhaseCorrelation() { return speedPhaseCorrelation; } + public void setSpeedPhaseCorrelation(Double speedPhaseCorrelation) { this.speedPhaseCorrelation = speedPhaseCorrelation; } + public Double getMaxJumpDistance() { return maxJumpDistance; } + public void setMaxJumpDistance(Double maxJumpDistance) { this.maxJumpDistance = maxJumpDistance; } + public Double getOvershootRatio() { return overshootRatio; } + public void setOvershootRatio(Double overshootRatio) { this.overshootRatio = overshootRatio; } + public String getTrackJson() { return trackJson; } + public void setTrackJson(String trackJson) { this.trackJson = trackJson; } + public String getIp() { return ip; } + public void setIp(String ip) { this.ip = ip; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/CaptchaLogRepository.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/CaptchaLogRepository.java new file mode 100644 index 0000000..9960563 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/CaptchaLogRepository.java @@ -0,0 +1,8 @@ +package cloud.tianai.captcha.platform.mapper; + +import cloud.tianai.captcha.platform.entity.CaptchaLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CaptchaLogRepository extends JpaRepository { + long countByIsPass(Boolean isPass); +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/CaptchaSiteRepository.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/CaptchaSiteRepository.java new file mode 100644 index 0000000..99866c3 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/CaptchaSiteRepository.java @@ -0,0 +1,10 @@ +package cloud.tianai.captcha.platform.mapper; + +import cloud.tianai.captcha.platform.entity.CaptchaSite; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import java.util.UUID; + +public interface CaptchaSiteRepository extends JpaRepository { + Optional findBySiteKey(UUID siteKey); +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/TrackSampleRepository.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/TrackSampleRepository.java new file mode 100644 index 0000000..fecadb3 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/TrackSampleRepository.java @@ -0,0 +1,25 @@ +package cloud.tianai.captcha.platform.mapper; + +import cloud.tianai.captcha.platform.entity.TrackSample; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.OffsetDateTime; +import java.util.List; + +@Repository +public interface TrackSampleRepository extends JpaRepository { + + long countByIsHuman(Boolean isHuman); + + @Query("SELECT COUNT(s) FROM TrackSample s WHERE s.createdAt > :since") + long countRecentSamples(@Param("since") OffsetDateTime since); + + @Query("SELECT s FROM TrackSample s ORDER BY s.createdAt DESC LIMIT :limit") + List findRecentSamples(@Param("limit") int limit); + + @Query("SELECT s FROM TrackSample s WHERE s.isHuman = :isHuman ORDER BY s.createdAt DESC LIMIT :limit") + List findByIsHuman(@Param("isHuman") Boolean isHuman, @Param("limit") int limit); +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/CaptchaPlatformService.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/CaptchaPlatformService.java new file mode 100644 index 0000000..44bf47a --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/CaptchaPlatformService.java @@ -0,0 +1,242 @@ +package cloud.tianai.captcha.platform.service; + +import cloud.tianai.captcha.application.ImageCaptchaApplication; +import cloud.tianai.captcha.application.vo.ImageCaptchaVO; +import cloud.tianai.captcha.common.AnyMap; +import cloud.tianai.captcha.common.response.ApiResponse; +import cloud.tianai.captcha.platform.entity.CaptchaLog; +import cloud.tianai.captcha.platform.entity.CaptchaSite; +import cloud.tianai.captcha.platform.mapper.CaptchaLogRepository; +import cloud.tianai.captcha.platform.mapper.CaptchaSiteRepository; +import cloud.tianai.captcha.risk.IpBlacklist; +import cloud.tianai.captcha.risk.RiskEngine; +import cloud.tianai.captcha.site.TokenService; +import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack; +import cloud.tianai.captcha.validator.common.model.dto.MatchParam; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.lang.reflect.Type; +import java.security.SecureRandom; +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; + +@Service +public class CaptchaPlatformService { + + private final ImageCaptchaApplication captchaApplication; + private final CaptchaSiteRepository siteRepository; + private final CaptchaLogRepository logRepository; + private final TokenService tokenService; + private final RiskEngine riskEngine; + private final IpBlacklist ipBlacklist; + private final TrackLearningService trackLearningService; + private final Gson gson = new Gson(); + + public CaptchaPlatformService(ImageCaptchaApplication captchaApplication, + CaptchaSiteRepository siteRepository, + CaptchaLogRepository logRepository, + TokenService tokenService, + RiskEngine riskEngine, + IpBlacklist ipBlacklist, + TrackLearningService trackLearningService) { + this.captchaApplication = captchaApplication; + this.siteRepository = siteRepository; + this.logRepository = logRepository; + this.tokenService = tokenService; + this.riskEngine = riskEngine; + this.ipBlacklist = ipBlacklist; + this.trackLearningService = trackLearningService; + } + + public ApiResponse generateCaptcha(String siteKeyStr, String type, String scene, String ip) { + CaptchaSite site = validateSite(siteKeyStr); + if (site == null) { + return ApiResponse.of(10001, "site_auth_fail", null); + } + + if (ipBlacklist.isBanned(ip)) { + return ApiResponse.of(10005, "ip_banned", null); + } + + if (!riskEngine.getRateLimiter().allow("ip:" + ip)) { + return ApiResponse.of(10007, "rate_limit", null); + } + + if (type == null) { + Set allowed = site.getCaptchaTypes(); + if (allowed != null && !allowed.isEmpty()) { + List list = new ArrayList<>(allowed); + type = list.get(ThreadLocalRandom.current().nextInt(list.size())); + } else { + type = "SLIDER"; + } + } + + if (site.getCaptchaTypes() != null && !site.getCaptchaTypes().isEmpty() + && !site.getCaptchaTypes().contains(type)) { + return ApiResponse.of(10006, "type_not_supported", null); + } + + ApiResponse response = captchaApplication.generateCaptcha(type); + if (response.isSuccess()) { + String cid = response.getData() != null ? response.getData().getId() : null; + System.out.println("[CAPTCHA-DEBUG] generateCaptcha type=" + type + " captchaId=" + cid); + CaptchaLog log = new CaptchaLog(); + log.setSiteId(site.getId()); + log.setSiteKey(site.getSiteKey()); + log.setCaptchaId(response.getData() != null ? response.getData().getId() : null); + log.setCaptchaType(type); + log.setScene(scene); + log.setIp(ip); + log.setIsPass(null); + logRepository.save(log); + } + return response; + } + + public ApiResponse verifyCaptcha(String siteKeyStr, String captchaId, Object trackData, String ip) { + CaptchaSite site = validateSite(siteKeyStr); + if (site == null) { + return ApiResponse.of(10001, "site_auth_fail", null); + } + + System.out.println("[CAPTCHA-DEBUG] verifyCaptcha captchaId=" + captchaId + " trackData class=" + (trackData != null ? trackData.getClass().getName() : "null")); + + ImageCaptchaTrack track; + if (trackData instanceof Map) { + Type mapType = new TypeToken>() {}.getType(); + String json = gson.toJson(trackData); + System.out.println("[CAPTCHA-DEBUG] track json=" + json); + track = gson.fromJson(json, ImageCaptchaTrack.class); + } else if (trackData instanceof ImageCaptchaTrack) { + track = (ImageCaptchaTrack) trackData; + } else { + return ApiResponse.of(10004, "invalid_param", null); + } + + System.out.println("[CAPTCHA-DEBUG] track.bgImageWidth=" + track.getBgImageWidth() + + " trackList.size=" + (track.getTrackList() != null ? track.getTrackList().size() : 0)); + + MatchParam matchParam = new MatchParam(track); + AnyMap extData = new AnyMap(); + extData.put("ip", ip); + matchParam.putAll(extData); + + long startTime = System.currentTimeMillis(); + ApiResponse result = captchaApplication.matching(captchaId, matchParam); + long duration = System.currentTimeMillis() - startTime; + + System.out.println("[CAPTCHA-DEBUG] matching result code=" + result.getCode() + " msg=" + result.getMsg() + " success=" + result.isSuccess()); + + CaptchaLog log = new CaptchaLog(); + log.setSiteId(site.getId()); + log.setSiteKey(site.getSiteKey()); + log.setCaptchaId(captchaId); + log.setCaptchaType(trackData != null ? "VERIFY" : "UNKNOWN"); + log.setIp(ip); + log.setIsPass(result.isSuccess()); + log.setCostTime((int) duration); + logRepository.save(log); + + trackLearningService.collectSample(track, result.isSuccess(), "SLIDER", site.getId(), ip); + + if (result.isSuccess()) { + String token = tokenService.generateToken(siteKeyStr, captchaId); + Map data = new HashMap<>(); + data.put("verifyToken", token); + return ApiResponse.ofSuccess(data); + } + + if (!result.isSuccess()) { + riskEngine.recordFail(ip); + } + + return result; + } + + public ApiResponse secondaryVerify(String siteKeyStr, String secretKeyStr, String verifyToken) { + CaptchaSite site = siteRepository.findBySiteKey(java.util.UUID.fromString(siteKeyStr)).orElse(null); + if (site == null || !site.getSecretKey().toString().equals(secretKeyStr)) { + return ApiResponse.of(10001, "site_auth_fail", null); + } + AnyMap tokenData = tokenService.consumeToken(verifyToken); + if (tokenData == null) { + return ApiResponse.of(403, "token_invalid", null); + } + Map result = new HashMap<>(); + result.put("verifyResult", true); + return ApiResponse.ofSuccess(result); + } + + public ApiResponse getPublicKey(String siteKeyStr) { + CaptchaSite site = validateSite(siteKeyStr); + if (site == null) { + return ApiResponse.of(10001, "site_auth_fail", null); + } + Map data = new HashMap<>(); + data.put("publicKey", site.getRsaPublicKey()); + return ApiResponse.ofSuccess(data); + } + + public CaptchaSite createSite(String name, String domain, Set captchaTypes) { + CaptchaSite site = new CaptchaSite(); + site.setSiteKey(java.util.UUID.randomUUID()); + site.setSecretKey(java.util.UUID.randomUUID()); + site.setName(name); + site.setDomain(domain); + site.setCaptchaTypes(captchaTypes); + try { + java.security.KeyPair keyPair = cloud.tianai.captcha.crypto.RsaEncryptor.generateKeyPair(); + site.setRsaPublicKey(cloud.tianai.captcha.crypto.RsaEncryptor.publicKeyToBase64(keyPair.getPublic())); + site.setRsaPrivateKey(cloud.tianai.captcha.crypto.RsaEncryptor.privateKeyToBase64(keyPair.getPrivate())); + } catch (Exception e) { + throw new RuntimeException("Failed to generate RSA key pair", e); + } + byte[] aesKey = cloud.tianai.captcha.crypto.AesEncryptor.generateKey(); + site.setAesKey(Base64.getEncoder().encodeToString(aesKey)); + byte[] signingKey = new byte[32]; + new SecureRandom().nextBytes(signingKey); + site.setSigningKey(Base64.getEncoder().encodeToString(signingKey)); + return siteRepository.save(site); + } + + public Page listSites(Pageable pageable) { + return siteRepository.findAll(pageable); + } + + public CaptchaSite getSite(Integer siteId) { return siteRepository.findById(siteId).orElse(null); } + public CaptchaSite updateSite(CaptchaSite site) { return siteRepository.save(site); } + public void deleteSite(Integer siteId) { siteRepository.deleteById(siteId); } + + public Map getStats(Integer siteId, int days) { + long total = logRepository.count(); + long success = logRepository.countByIsPass(true); + long fail = logRepository.countByIsPass(false); + Map stats = new HashMap<>(); + stats.put("total", total); + stats.put("success", success); + stats.put("fail", fail); + stats.put("passRate", total > 0 ? String.format("%.2f%%", success * 100.0 / total) : "0%"); + return stats; + } + + public void banIp(String ip, String reason, long durationMs) { ipBlacklist.ban(ip, durationMs); } + public void unbanIp(String ip) { ipBlacklist.unban(ip); } + + private CaptchaSite validateSite(String siteKeyStr) { + if (siteKeyStr == null) return null; + try { + java.util.UUID uuid = java.util.UUID.fromString(siteKeyStr); + Optional opt = siteRepository.findBySiteKey(uuid); + return opt.filter(site -> site.getIsEnabled()).orElse(null); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/TrackLearningService.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/TrackLearningService.java new file mode 100644 index 0000000..e514a7e --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/TrackLearningService.java @@ -0,0 +1,329 @@ +package cloud.tianai.captcha.platform.service; + +import cloud.tianai.captcha.ml.TrackFeatureExtractor; +import cloud.tianai.captcha.ml.TrackFeatures; +import cloud.tianai.captcha.ml.TrackRuleEngine; +import cloud.tianai.captcha.ml.TrackRuleEngine.Rule; +import cloud.tianai.captcha.ml.TrackRuleEngine.RuleResult; +import cloud.tianai.captcha.ml.TrackRuleEngine.TrackVerdict; +import cloud.tianai.captcha.platform.entity.TrackSample; +import cloud.tianai.captcha.platform.mapper.TrackSampleRepository; +import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack; +import cloud.tianai.captcha.validator.impl.EnhancedTrackValidator; +import com.google.gson.Gson; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +@Service +public class TrackLearningService implements CommandLineRunner { + + private static final Logger log = LoggerFactory.getLogger(TrackLearningService.class); + private static final String MODEL_PATH = "ml-model/track-rules.json"; + private static final int MIN_SAMPLES_FOR_TRAINING = 20; + + private final TrackSampleRepository sampleRepository; + private final TrackFeatureExtractor featureExtractor = new TrackFeatureExtractor(); + private TrackRuleEngine ruleEngine; + private final EnhancedTrackValidator validator; + private final Gson gson = new Gson(); + + private final AtomicBoolean trainingInProgress = new AtomicBoolean(false); + private final AtomicInteger totalSamples = new AtomicInteger(0); + private final AtomicInteger humanSamples = new AtomicInteger(0); + private final AtomicInteger botSamples = new AtomicInteger(0); + private final AtomicLong lastTrainingTime = new AtomicLong(0); + private volatile String lastTrainingSummary = "未训练"; + + public TrackLearningService(TrackSampleRepository sampleRepository, EnhancedTrackValidator validator) { + this.sampleRepository = sampleRepository; + this.validator = validator; + this.ruleEngine = validator.getRuleEngine(); + } + + public void collectSample(ImageCaptchaTrack track, boolean basicPass, String captchaType, Integer siteId, String ip) { + try { + TrackFeatures features = featureExtractor.extract(track); + TrackVerdict verdict = ruleEngine.evaluate(features); + boolean isHuman = basicPass && verdict.isHuman; + + String trackJson = gson.toJson(track); + TrackSample sample = TrackSample.fromFeatures(features, isHuman, verdict.score, basicPass, captchaType, siteId, trackJson, ip); + sampleRepository.save(sample); + + totalSamples.incrementAndGet(); + if (isHuman) humanSamples.incrementAndGet(); + else botSamples.incrementAndGet(); + + log.debug("[ML] 收集轨迹样本 isHuman={} mlScore={:.3f} basicPass={}", isHuman, verdict.score, basicPass); + } catch (Exception e) { + log.warn("[ML] 收集轨迹样本失败: {}", e.getMessage()); + } + } + + @Scheduled(fixedDelay = 300000, initialDelay = 60000) + public void scheduledTraining() { + trainIfNeeded(); + } + + public synchronized Map trainIfNeeded() { + long total = sampleRepository.count(); + if (total < MIN_SAMPLES_FOR_TRAINING) { + lastTrainingSummary = "样本不足 (" + total + "/" + MIN_SAMPLES_FOR_TRAINING + "),跳过训练"; + return Map.of("status", "skipped", "reason", lastTrainingSummary); + } + if (!trainingInProgress.compareAndSet(false, true)) { + return Map.of("status", "busy", "reason", "训练正在进行中"); + } + try { + Map result = doTrain(); + lastTrainingTime.set(System.currentTimeMillis()); + return result; + } finally { + trainingInProgress.set(false); + } + } + + private Map doTrain() { + List allSamples = sampleRepository.findAll(); + List humanList = new ArrayList<>(); + List botList = new ArrayList<>(); + for (TrackSample s : allSamples) { + if (s.getIsHuman() != null) { + if (s.getIsHuman()) humanList.add(s); + else botList.add(s); + } + } + + if (humanList.size() < 5 || botList.size() < 5) { + lastTrainingSummary = String.format("正负样本不均衡 human=%d bot=%d", humanList.size(), botList.size()); + return Map.of("status", "skipped", "reason", lastTrainingSummary); + } + + Map humanStats = computeStats(humanList); + Map botStats = computeStats(botList); + + List learnedRules = new ArrayList<>(); + String[] featureNames = { + "totalDuration", "totalPoints", "startOffset", "speedVariance", + "straightness", "yDirectionChanges", "speedPhaseCorrelation", + "maxJumpDistance", "pauses", "overshootRatio", + "accelerationVariance", "xUniformity", "speedSkewness", "pathEfficiency" + }; + + for (String name : featureNames) { + double[] hStat = humanStats.get(name); + double[] bStat = botStats.get(name); + if (hStat == null || bStat == null) continue; + + double hMean = hStat[0], hStd = hStat[1]; + double bMean = bStat[0], bStd = bStat[1]; + + double separation = Math.abs(hMean - bMean) / (hStd + bStd + 0.001); + double weight = Math.min(2.0, 0.5 + separation * 0.5); + + double lowThreshold, highThreshold; + if (hMean < bMean) { + lowThreshold = hMean - 2 * hStd; + highThreshold = hMean + 2 * hStd; + } else { + lowThreshold = bMean - 2 * bStd; + highThreshold = bMean + 2 * bStd; + } + + learnedRules.add(new LearnedRule(name, weight, hMean, bMean, hStd, bStd, lowThreshold, highThreshold, separation)); + } + + learnedRules.sort((a, b) -> Double.compare(b.separation, a.separation)); + + saveModel(learnedRules); + applyModel(learnedRules); + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("训练完成: %d样本(human=%d,bot=%d), Top规则:\n", allSamples.size(), humanList.size(), botList.size())); + for (int i = 0; i < Math.min(5, learnedRules.size()); i++) { + LearnedRule r = learnedRules.get(i); + sb.append(String.format(" %d. %s (分离度=%.2f, 权重=%.2f)\n", i + 1, r.name, r.separation, r.weight)); + } + lastTrainingSummary = sb.toString(); + log.info("[ML] {}", lastTrainingSummary); + + return Map.of( + "status", "trained", + "totalSamples", allSamples.size(), + "humanSamples", humanList.size(), + "botSamples", botList.size(), + "rules", learnedRules.size(), + "topRules", learnedRules.subList(0, Math.min(5, learnedRules.size())) + ); + } + + private Map computeStats(List samples) { + Map> buckets = new LinkedHashMap<>(); + String[] featureNames = { + "totalDuration", "totalPoints", "startOffset", "speedVariance", + "straightness", "yDirectionChanges", "speedPhaseCorrelation", + "maxJumpDistance", "pauses", "overshootRatio", + "accelerationVariance", "xUniformity", "speedSkewness", "pathEfficiency" + }; + for (String name : featureNames) buckets.put(name, new ArrayList<>()); + + for (TrackSample s : samples) { + addVal(buckets, "totalDuration", s.getTotalDuration()); + addVal(buckets, "totalPoints", s.getTotalPoints()); + addVal(buckets, "startOffset", s.getStartOffset()); + addVal(buckets, "speedVariance", s.getSpeedVariance()); + addVal(buckets, "straightness", s.getStraightness()); + addVal(buckets, "yDirectionChanges", s.getYDirectionChanges()); + addVal(buckets, "speedPhaseCorrelation", s.getSpeedPhaseCorrelation()); + addVal(buckets, "maxJumpDistance", s.getMaxJumpDistance()); + addVal(buckets, "pauses", s.getPauses()); + addVal(buckets, "overshootRatio", s.getOvershootRatio()); + addVal(buckets, "accelerationVariance", s.getAccelerationVariance()); + addVal(buckets, "xUniformity", s.getXUniformity()); + addVal(buckets, "speedSkewness", s.getSpeedSkewness()); + addVal(buckets, "pathEfficiency", s.getPathEfficiency()); + } + + Map stats = new LinkedHashMap<>(); + for (Map.Entry> e : buckets.entrySet()) { + List vals = e.getValue(); + if (vals.isEmpty()) { stats.put(e.getKey(), new double[]{0, 0}); continue; } + double mean = vals.stream().mapToDouble(d -> d).average().orElse(0); + double std = Math.sqrt(vals.stream().mapToDouble(d -> (d - mean) * (d - mean)).average().orElse(0)); + stats.put(e.getKey(), new double[]{mean, std}); + } + return stats; + } + + private void addVal(Map> buckets, String key, Number val) { + if (val != null) buckets.get(key).add(val.doubleValue()); + } + + private void applyModel(List learnedRules) { + ruleEngine.getRules().clear(); + for (LearnedRule lr : learnedRules) { + ruleEngine.addRule(new Rule("learned_" + lr.name, lr.weight, f -> { + double val = getFeatureValue(f, lr.name); + if (lr.hMean < lr.bMean) { + if (val < lr.lowThreshold) return new RuleResult(0, lr.name + " too low: " + String.format("%.3f", val)); + if (val > lr.highThreshold) return new RuleResult(0.3, lr.name + " high: " + String.format("%.3f", val)); + return new RuleResult(1, lr.name + " OK: " + String.format("%.3f", val)); + } else { + if (val > lr.highThreshold) return new RuleResult(0, lr.name + " too high: " + String.format("%.3f", val)); + if (val < lr.lowThreshold) return new RuleResult(0.3, lr.name + " low: " + String.format("%.3f", val)); + return new RuleResult(1, lr.name + " OK: " + String.format("%.3f", val)); + } + })); + } + log.info("[ML] 已应用 {} 条学习规则到 TrackRuleEngine", learnedRules.size()); + } + + private double getFeatureValue(TrackFeatures f, String name) { + return switch (name) { + case "totalDuration" -> f.totalDuration; + case "totalPoints" -> f.totalPoints; + case "startOffset" -> f.startOffset; + case "speedVariance" -> f.speedVariance; + case "straightness" -> f.straightness; + case "yDirectionChanges" -> f.yDirectionChanges; + case "speedPhaseCorrelation" -> f.speedPhaseCorrelation; + case "maxJumpDistance" -> f.maxJumpDistance; + case "pauses" -> f.pauses; + case "overshootRatio" -> f.overshootRatio; + case "accelerationVariance" -> f.accelerationVariance; + case "xUniformity" -> f.xUniformity; + case "speedSkewness" -> f.speedSkewness; + case "pathEfficiency" -> f.pathEfficiency; + default -> 0; + }; + } + + private void saveModel(List rules) { + try { + Path dir = Paths.get(MODEL_PATH).getParent(); + if (dir != null) Files.createDirectories(dir); + String json = gson.toJson(rules); + Files.writeString(Paths.get(MODEL_PATH), json); + log.info("[ML] 模型已保存到 {}", MODEL_PATH); + } catch (Exception e) { + log.warn("[ML] 保存模型失败: {}", e.getMessage()); + } + } + + private void loadModel() { + try { + Path path = Paths.get(MODEL_PATH); + if (!Files.exists(path)) { + log.info("[ML] 无已保存模型,使用默认规则"); + return; + } + String json = Files.readString(path); + List rules = gson.fromJson(json, new com.google.gson.reflect.TypeToken>() {}.getType()); + if (rules != null && !rules.isEmpty()) { + applyModel(rules); + log.info("[ML] 已从 {} 加载 {} 条学习规则", MODEL_PATH, rules.size()); + } + } catch (Exception e) { + log.warn("[ML] 加载模型失败: {}", e.getMessage()); + } + } + + public Map getStatus() { + Map status = new LinkedHashMap<>(); + status.put("totalSamples", totalSamples.get()); + status.put("humanSamples", humanSamples.get()); + status.put("botSamples", botSamples.get()); + status.put("lastTrainingTime", lastTrainingTime.get()); + status.put("lastTrainingSummary", lastTrainingSummary); + status.put("trainingInProgress", trainingInProgress.get()); + status.put("activeRuleCount", ruleEngine.getRules().size()); + return status; + } + + @Override + public void run(String... args) { + try { + totalSamples.set((int) sampleRepository.count()); + humanSamples.set((int) sampleRepository.countByIsHuman(true)); + botSamples.set((int) sampleRepository.countByIsHuman(false)); + } catch (Exception e) { + log.warn("[ML] 初始化样本统计失败(表可能尚未创建): {}", e.getMessage()); + } + loadModel(); + log.info("[ML] 轨迹学习服务启动 样本总数={} human={} bot={}", totalSamples.get(), humanSamples.get(), botSamples.get()); + } + + private static class LearnedRule { + String name; + double weight; + double hMean, bMean, hStd, bStd; + double lowThreshold, highThreshold; + double separation; + + LearnedRule() {} + + LearnedRule(String name, double weight, double hMean, double bMean, double hStd, double bStd, double lowThreshold, double highThreshold, double separation) { + this.name = name; + this.weight = weight; + this.hMean = hMean; + this.bMean = bMean; + this.hStd = hStd; + this.bStd = bStd; + this.lowThreshold = lowThreshold; + this.highThreshold = highThreshold; + this.separation = separation; + } + } +} diff --git a/tianai-captcha-platform/src/main/resources/application.yml b/tianai-captcha-platform/src/main/resources/application.yml new file mode 100644 index 0000000..6739560 --- /dev/null +++ b/tianai-captcha-platform/src/main/resources/application.yml @@ -0,0 +1,36 @@ +server: + port: 18200 + +spring: + datasource: + url: jdbc:postgresql://localhost:5432/captcha_forge + username: postgres + password: XGYnJPysCNJsLeea + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: none + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + sql: + init: + mode: always + + data: + redis: + host: localhost + port: 6379 + +captcha: + prefix: captcha + expire: + default: 120000 + init-default-resource: true + local-cache-enabled: true + local-cache-size: 20 + +logging: + level: + cloud.tianai.captcha: INFO diff --git a/tianai-captcha-platform/src/main/resources/db/migration/V1__init.sql b/tianai-captcha-platform/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..3241c51 --- /dev/null +++ b/tianai-captcha-platform/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,233 @@ +DROP TABLE IF EXISTS verification_logs CASCADE; +DROP TABLE IF EXISTS track_samples CASCADE; +DROP TABLE IF EXISTS captcha_challenges CASCADE; +DROP TABLE IF EXISTS captcha_categories CASCADE; +DROP TABLE IF EXISTS sites CASCADE; +DROP TABLE IF EXISTS plans CASCADE; +DROP TABLE IF EXISTS users CASCADE; +DROP TABLE IF EXISTS announcements CASCADE; +DROP TABLE IF EXISTS captcha_ip_blacklist CASCADE; + +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + email VARCHAR(128), + password VARCHAR(256) NOT NULL, + role VARCHAR(16) DEFAULT 'USER', + site_amount INT DEFAULT 0, + is_enabled BOOLEAN DEFAULT TRUE, + is_system BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE plans ( + id SERIAL PRIMARY KEY, + name VARCHAR(64) NOT NULL, + description VARCHAR(256), + qps INT DEFAULT 10, + daily_limit INT DEFAULT 500, + captcha_types TEXT[] DEFAULT '{SLIDER,PUZZLE,TEXT_CLICK,ICON_CLICK,ICON_UNDERSTAND}', + custom_style BOOLEAN DEFAULT FALSE, + is_enabled BOOLEAN DEFAULT TRUE, + is_system BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE sites ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id), + name VARCHAR(64) NOT NULL, + domain VARCHAR(256), + favicon VARCHAR(512), + logo VARCHAR(512), + site_key UUID DEFAULT gen_random_uuid() UNIQUE, + secret_key UUID DEFAULT gen_random_uuid() UNIQUE, + verify_level VARCHAR(16) DEFAULT 'MEDIUM', + qps INT DEFAULT 10, + daily_limit INT DEFAULT 500, + captcha_types TEXT[] DEFAULT '{SLIDER,PUZZLE,TEXT_CLICK,ICON_CLICK,ICON_UNDERSTAND}', + plan_id INT REFERENCES plans(id), + plan_expire_at TIMESTAMPTZ, + is_enabled BOOLEAN DEFAULT TRUE, + rsa_public_key TEXT, + rsa_private_key TEXT, + aes_key VARCHAR(256), + signing_key VARCHAR(256), + track_validation_enabled BOOLEAN DEFAULT TRUE, + track_human_threshold FLOAT DEFAULT 0.5, + obfuscation_enabled BOOLEAN DEFAULT TRUE, + encryption_enabled BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE captcha_categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(64) NOT NULL, + label VARCHAR(64) NOT NULL, + items TEXT[] NOT NULL DEFAULT '{}', + is_enabled BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE captcha_challenges ( + id SERIAL PRIMARY KEY, + category_id INT REFERENCES captcha_categories(id) ON DELETE CASCADE, + prompt TEXT NOT NULL, + correct_items TEXT[] NOT NULL DEFAULT '{}', + difficulty VARCHAR(16) DEFAULT 'MEDIUM', + is_enabled BOOLEAN DEFAULT TRUE, + use_count INT DEFAULT 0, + success_rate DOUBLE PRECISION DEFAULT 0.5, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE verification_logs ( + id SERIAL PRIMARY KEY, + site_id INT REFERENCES sites(id), + site_key UUID, + captcha_type VARCHAR(32), + scene VARCHAR(64) DEFAULT 'default', + ip VARCHAR(45), + is_pass BOOLEAN, + behavior_score DOUBLE PRECISION, + risk_level VARCHAR(16), + cost_time INT, + captcha_id VARCHAR(128), + user_agent TEXT, + track_score FLOAT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE track_samples ( + id SERIAL PRIMARY KEY, + site_id INT REFERENCES sites(id), + captcha_type VARCHAR(32), + is_human BOOLEAN, + ml_score DOUBLE PRECISION, + basic_pass BOOLEAN, + total_points INT, + total_duration BIGINT, + displacement_x FLOAT, + displacement_y FLOAT, + displacement_x_ratio FLOAT, + total_path_length DOUBLE PRECISION, + path_efficiency DOUBLE PRECISION, + avg_speed FLOAT, + max_speed FLOAT, + min_speed FLOAT, + speed_variance DOUBLE PRECISION, + speed_std_dev DOUBLE PRECISION, + speed_skewness DOUBLE PRECISION, + avg_acceleration FLOAT, + max_acceleration FLOAT, + min_acceleration FLOAT, + acceleration_variance DOUBLE PRECISION, + direction_changes INT, + y_direction_changes INT, + pauses INT, + start_offset DOUBLE PRECISION, + straightness DOUBLE PRECISION, + x_uniformity DOUBLE PRECISION, + y_uniformity DOUBLE PRECISION, + avg_point_interval FLOAT, + speed_phase_correlation DOUBLE PRECISION, + max_jump_distance DOUBLE PRECISION, + overshoot_ratio DOUBLE PRECISION, + track_json TEXT, + ip VARCHAR(45), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE announcements ( + id SERIAL PRIMARY KEY, + title VARCHAR(256) NOT NULL, + content TEXT, + is_pinned BOOLEAN DEFAULT FALSE, + is_published BOOLEAN DEFAULT TRUE, + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE captcha_ip_blacklist ( + id SERIAL PRIMARY KEY, + ip VARCHAR(64) NOT NULL, + reason VARCHAR(256), + ban_until TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_sites_key ON sites(site_key); +CREATE INDEX idx_sites_user ON sites(user_id); +CREATE INDEX idx_challenges_category ON captcha_challenges(category_id); +CREATE INDEX idx_challenges_difficulty ON captcha_challenges(difficulty); +CREATE INDEX idx_logs_site ON verification_logs(site_id); +CREATE INDEX idx_logs_time ON verification_logs(created_at); +CREATE INDEX idx_logs_type ON verification_logs(captcha_type); +CREATE INDEX idx_logs_pass ON verification_logs(is_pass); +CREATE INDEX idx_ip_blacklist_ip ON captcha_ip_blacklist(ip); +CREATE INDEX idx_track_human ON track_samples(is_human); +CREATE INDEX idx_track_type ON track_samples(captcha_type); +CREATE INDEX idx_track_time ON track_samples(created_at); +CREATE INDEX idx_track_score ON track_samples(ml_score); + +INSERT INTO users (username, email, password, role, site_amount, is_enabled, is_system) +VALUES ('admin', 'admin@captchaforge.local', '$2a$10$85QOiy3qD5KPuxyrrN/LcuZe3ty/OrTk6yEKAJEGtJKk97ukw2yPG', 'ADMIN', 999, TRUE, TRUE); + +INSERT INTO plans (name, description, qps, daily_limit, is_enabled, is_system) VALUES +('免费套餐', '默认免费套餐', 5, 500, TRUE, TRUE), +('基础套餐', '适合中小站点', 20, 5000, TRUE, TRUE), +('专业套餐', '适合大型站点', 100, 50000, TRUE, TRUE); + +INSERT INTO captcha_categories (name, label, items) VALUES +('animals', '动物', '{"🐶","🐱","🐭","🐹","🐰","🦊","🐻","🐼","🐨","🐯","🦁","🐮","🐷","🐸","🐵","🐔","🐧","🐦","🦅","🦆","🦉","🐴","🦄","🐝","🐛","🦋","🐌","🐞","🐢","🐍","🦎","🐙","🦑","🦐","🦀","🐠","🐟","🐬","🐳","🐊","🐘","🦏","🐪","🦒","🐕","🐈","🐓","🦃","🦚","🦜","🐇","🦔","🐺","🐗"}'), +('food', '食物饮品', '{"🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🍒","🍑","🥭","🍍","🥥","🥝","🍅","🍆","🥑","🥦","🌽","🥕","🍞","🧀","🍳","🍔","🍟","🍕","🌮","🍣","🍜","🍩","🍪","🎂","🍫","🍭","☕","🍵","🧃","🥤","🍺","🍷"}'), +('vehicles', '交通工具', '{"🚗","🚕","🚙","🚌","🏎","🚓","🚑","🚒","🚐","🚚","🚛","🚜","🚲","🛵","🏍","🚨","🚔","🚡","🚠","🚃","🚋","🚄","🚅","🚂","✈️","🛩","🚀","🛸","🚁","⛵","🚤","🛳","⛴","🚢"}'), +('nature', '天气自然', '{"☀️","🌤","⛅","🌥","☁️","🌦","🌧","⛈","🌩","🌨","❄️","☃️","🌪","🌈","🌊","💧","🔥","⭐","🌟","✨","⚡","☄️","🌸","🌺","🌻","🌹","🌷","🌱","🌿","🍀","🍁","🍂","🍃","🌴","🌵"}'), +('sports', '运动娱乐', '{"⚽","🏀","🏈","⚾","🎾","🏐","🏉","🎱","🏓","🏸","🏒","🏑","🥍","🎯","🎳","🎮","🎲","♟","🧩","🪀","🪁","🎪","🤹","🎭","🎨","🎬","🎤","🎧","🎹","🥁","🎸","🎻"}'), +('buildings', '建筑地点', '{"🏠","🏡","🏢","🏣","🏤","🏥","🏦","🏨","🏩","🏪","🏫","🏬","🏭","🏯","🏰","💒","🗼","🗽","⛪","🕌","🛕","🕍","⛩","🕋","⛲","⛺","🏕"}'), +('objects', '电子物品', '{"⌚","📱","💻","⌨️","🖥","🖨","🖱","🖲","💾","💿","📷","📹","🎥","📞","📺","📻","🔋","🔌","💡","🔦","🕯","🔑","🔒","🔓","📧","📮","📦","📋","📁","✏️","🖊","🖋","✒️","🖌","📝","🔍","📎","📐","📌","✂️","🧲","🔧","🔨","⚙️","💊","💉","🩺","🧬","🔭","🔬","🧪"}'), +('gestures', '手势动作', '{"👋","🤚","🖐","✋","🖖","👌","🤌","🤏","✌","🤞","🤟","🤘","🤙","👈","👉","👆","👇","☝️","👍","👎","✊","👊","🤛","🤜","👏","🙌","👐","🤲","🤝","🙏","✍️","💅","🤳","💪"}'); + +INSERT INTO captcha_challenges (category_id, prompt, correct_items, difficulty) VALUES +(1, '请点击所有的猫科动物', '{"🐱","🐯","🦁","🐈"}', 'MEDIUM'), +(1, '请点击所有的犬科动物', '{"🐶","🐺","🐕"}', 'MEDIUM'), +(1, '请点击所有的鸟类', '{"🐔","🐧","🐦","🦅","🦆","🦉","🦜","🦚","🦃"}', 'MEDIUM'), +(1, '请点击所有的水生动物', '{"🐙","🦑","🦐","🦀","🐠","🐟","🐬","🐳"}', 'MEDIUM'), +(1, '请点击所有的昆虫', '{"🐝","🐛","🦋","🐌","🐞","🦟"}', 'MEDIUM'), +(2, '请点击所有的水果', '{"🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🍒","🍑","🥭","🍍","🥝"}', 'MEDIUM'), +(2, '请点击所有的蔬菜', '{"🍅","🍆","🥑","🥦","🌽","🥕"}', 'MEDIUM'), +(2, '请点击所有的饮品', '{"☕","🍵","🧃","🥤","🍺","🍷"}', 'MEDIUM'), +(2, '请点击所有的甜点', '{"🍩","🍪","🎂","🍫","🍭"}', 'MEDIUM'), +(3, '请点击所有的汽车', '{"🚗","🚕","🚙","🏎","🚓","🚑","🚒","🚐"}', 'MEDIUM'), +(3, '请点击所有的飞行器', '{"✈️","🛩","🚀","🛸","🚁"}', 'MEDIUM'), +(3, '请点击所有的船只', '{"⛵","🚤","🛳","⛴","🚢"}', 'MEDIUM'), +(4, '请点击所有与降水相关的', '{"🌦","🌧","⛈","🌩","🌨"}', 'MEDIUM'), +(4, '请点击所有的花卉', '{"🌸","🌺","🌻","🌹","🌷"}', 'MEDIUM'), +(4, '请点击所有的天体', '{"☀️","⭐","🌟","✨","⚡","☄️"}', 'MEDIUM'), +(5, '请点击所有的球类运动', '{"⚽","🏀","🏈","⚾","🎾","🏐","🏉"}', 'MEDIUM'), +(5, '请点击所有的音乐相关', '{"🎤","🎧","🎹","🥁","🎸","🎻"}', 'MEDIUM'), +(5, '请点击所有的棋牌游戏', '{"🎲","♟","🧩","🎮","🎯","🎱"}', 'MEDIUM'), +(1, '请点击所有的动物', '{"🐶","🐱","🐭","🐹","🐰","🦊","🐻","🐼","🐨","🐯","🦁","🐮","🐷","🐸","🐵","🐔","🐧","🐦","🦅","🦆","🦉","🐴","🦄","🐝","🐛","🦋","🐌","🐞","🐢","🐍","🦎","🐙","🦑","🦐","🦀","🐠","🐟","🐬","🐳","🐊","🐘","🦏","🐪","🦒","🐕","🐈","🐓","🦃","🦚","🦜","🐇","🦔","🐺","🐗"}', 'LOW'), +(2, '请点击所有的食物饮品', '{"🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🍒","🍑","🥭","🍍","🥥","🥝","🍅","🍆","🥑","🥦","🌽","🥕","🍞","🧀","🍳","🍔","🍟","🍕","🌮","🍣","🍜","🍩","🍪","🎂","🍫","🍭","☕","🍵","🧃","🥤","🍺","🍷"}', 'LOW'), +(3, '请点击所有的交通工具', '{"🚗","🚕","🚙","🚌","🏎","🚓","🚑","🚒","🚐","🚚","🚛","🚜","🚲","🛵","🏍","🚨","🚔","🚡","🚠","🚃","🚋","🚄","🚅","🚂","✈️","🛩","🚀","🛸","🚁","⛵","🚤","🛳","⛴","🚢"}', 'LOW'), +(4, '请点击所有的天气自然', '{"☀️","🌤","⛅","🌥","☁️","🌦","🌧","⛈","🌩","🌨","❄️","☃️","🌪","🌈","🌊","💧","🔥","⭐","🌟","✨","⚡","☄️","🌸","🌺","🌻","🌹","🌷","🌱","🌿","🍀","🍁","🍂","🍃","🌴","🌵"}', 'LOW'); + +INSERT INTO announcements (title, content, is_pinned, is_published, sort_order) VALUES +('tianai-captcha-enhanced 2.0.0 上线', '# tianai-captcha-enhanced + +基于 tianai-captcha 开源版的增强版行为验证码平台 + +## 特性 +- 16种验证码类型 +- ML轨迹校验器(28维特征+14条规则) +- 对抗扰动防YOLO +- 行为风控引擎 +- 端到端加密(AES-256+RSA-4096) +- 背景乱序/正弦扭曲/噪声注入 +- IP黑名单+滑动窗口限流', TRUE, TRUE, 0); diff --git a/tianai-captcha-sdk/package.json b/tianai-captcha-sdk/package.json new file mode 100644 index 0000000..b4ae46b --- /dev/null +++ b/tianai-captcha-sdk/package.json @@ -0,0 +1,12 @@ +{ + "name": "tianai-captcha-sdk", + "version": "2.0.0", + "description": "tianai-captcha-enhanced 前端SDK,兼容TPCaptcha API", + "main": "dist/captcha.min.js", + "scripts": { + "build": "terser src/captcha.js -o dist/captcha.min.js -c -m", + "dev": "echo 'Copy src/captcha.js to your project for development'" + }, + "keywords": ["captcha", "slider", "puzzle", "tianai", "verification"], + "license": "Apache-2.0" +} diff --git a/tianai-captcha-sdk/src/captcha.js b/tianai-captcha-sdk/src/captcha.js new file mode 100644 index 0000000..ddc566a --- /dev/null +++ b/tianai-captcha-sdk/src/captcha.js @@ -0,0 +1,1206 @@ +/** + * tianai-captcha-enhanced SDK + * 兼容 TPCaptcha API (TPCaptcha.init / onSuccess / verifyToken) + * + * Usage: + * TACaptcha.init({ + * elId: 'captcha-box', + * siteKey: 'your-site-key', + * type: 'SLIDER', // optional + * scene: 'login', // optional + * serverUrl: 'https://your-server/api', // optional + * onSuccess: (result) => { console.log(result.data.verifyToken); }, + * onFail: () => { console.log('failed'); } + * }); + */ +(function (global) { + 'use strict'; + + const INSTANCES = new Map(); + + class TACaptchaInstance { + constructor(options) { + this.options = Object.assign({ + elId: null, + siteKey: '', + mode: 'click', + type: null, + scene: 'default', + serverUrl: '/api', + onSuccess: null, + onFail: null, + onOpen: null, + onClose: null, + onRefresh: null, + }, options); + + this.container = null; + this.modal = null; + this.captchaData = null; + this.verifyToken = null; + this.isDragging = false; + this.startX = 0; + this.currentX = 0; + this.trackList = []; + this.startTime = 0; + this.isDark = this._detectTheme(); + this._themeObserver = null; + + this._init(); + } + + _detectTheme() { + if (this.options.theme === 'dark') return true; + if (this.options.theme === 'light') return false; + if (document.body.classList.contains('dark') || document.documentElement.classList.contains('dark')) return true; + if (document.documentElement.getAttribute('data-theme') === 'dark') return true; + if (document.documentElement.getAttribute('color-scheme') === 'dark') return true; + if (document.documentElement.style.colorScheme === 'dark') return true; + return false; + } + + _watchTheme() { + if (this._themeObserver) this._themeObserver.disconnect(); + const targets = [document.body, document.documentElement]; + this._themeObserver = new MutationObserver(() => { + const newDark = this._detectTheme(); + if (newDark !== this.isDark) { + this.isDark = newDark; + this._createTriggerButton(); + } + }); + targets.forEach(t => { + if (t) this._themeObserver.observe(t, { attributes: true, attributeFilter: ['class', 'data-theme', 'color-scheme'] }); + }); + if (window.matchMedia) { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + this._mqHandler = () => { + const newDark = this._detectTheme(); + if (newDark !== this.isDark) { + this.isDark = newDark; + this._createTriggerButton(); + } + }; + mq.addEventListener('change', this._mqHandler); + } + } + + _init() { + if (this.options.mode === 'event') { + this._createModal(); + return; + } + this._createTriggerButton(); + this._createModal(); + this._watchTheme(); + } + + _createTriggerButton() { + const el = document.getElementById(this.options.elId); + if (!el) return; + this.container = el; + + const typeLabel = this.options.type || '智能验证'; + + // 注入呼吸灯 + 盾牌动画样式 + if (!document.getElementById('tacaptcha-breathing-style')) { + const style = document.createElement('style'); + style.id = 'tacaptcha-breathing-style'; + style.textContent = + '@keyframes tacaptcha-breathe{0%,100%{box-shadow:0 0 0 0 rgba(82,196,26,0.4)}50%{box-shadow:0 0 0 6px rgba(82,196,26,0)}}' + + '.tacaptcha-dot{width:8px;height:8px;border-radius:50%;background:#52c41a;animation:tacaptcha-breathe 2s ease-in-out infinite}' + + '.tacaptcha-shield .shield-shard{transform-origin:50px 50px;transition:transform 2s cubic-bezier(0.22,1,0.36,1),fill 2s ease,opacity 2s ease}' + + '.tacaptcha-shield .ward-group{transition:opacity 2s ease}' + + '.tacaptcha-shield .ward-pulse{transform-origin:50px 50px;animation:pulse-ward 2s cubic-bezier(0.1,0.7,0.3,1) infinite}' + + '.tacaptcha-shield .ward-pulse.delay{animation-delay:1s}' + + '@keyframes pulse-ward{0%{transform:scale(0.6);opacity:1;stroke-width:6px}100%{transform:scale(1.8);opacity:0;stroke-width:1px}}' + + '.tacaptcha-shield .sword-slide{transform:translateY(-85px);opacity:0;transition:transform 2s cubic-bezier(0.175,0.885,0.32,1.4),opacity 2s ease}' + + '.tacaptcha-shield .sword-anchor-a{transform-origin:50px 50px;transform:rotate(-45deg)}' + + '.tacaptcha-shield .sword-anchor-b{transform-origin:50px 50px;transform:rotate(45deg)}' + + '.tacaptcha-shield.state-wait .ward-group{opacity:1}' + + '.tacaptcha-shield.state-success .ward-group,.tacaptcha-shield.state-fail .ward-group{opacity:0}' + + '.tacaptcha-shield.state-success .ward-pulse,.tacaptcha-shield.state-fail .ward-pulse{animation:none}' + + '.tacaptcha-shield.state-wait .shield-shard{fill:url(#tacaptcha-mb);transform:translate(0,0) scale(1) rotate(0deg);opacity:1;filter:drop-shadow(0 6px 8px rgba(0,0,0,0.6))}' + + '.tacaptcha-shield.state-success .shield-shard{fill:url(#tacaptcha-mg);transform:translate(0,0) scale(1) rotate(0deg);opacity:1;filter:drop-shadow(0 6px 8px rgba(0,0,0,0.6))}' + + '.tacaptcha-shield.state-success .sword-slide{transform:translateY(-15px);opacity:1}' + + '.tacaptcha-shield.state-fail .shield-shard{fill:url(#tacaptcha-mr);opacity:0.9;filter:none}' + + '.tacaptcha-shield.state-fail .shard-tl{transform:translate(-25px,-30px) scale(0.9) rotateX(30deg) rotateY(-20deg) rotateZ(-15deg);opacity:0.7}' + + '.tacaptcha-shield.state-fail .shard-tr{transform:translate(25px,-30px) scale(0.9) rotateX(30deg) rotateY(20deg) rotateZ(15deg);opacity:0.7}' + + '.tacaptcha-shield.state-fail .shard-b{transform:translate(0,35px) scale(0.8) rotateX(-20deg) rotateZ(5deg);opacity:0.5}'; + document.head.appendChild(style); + } + + const bgColor = this.isDark ? '#1a1a2e' : '#fff'; + const borderColor = this.isDark ? '#2a2a4a' : '#e8e8e8'; + const textColor = this.isDark ? '#e0e0e0' : '#333'; + const subColor = this.isDark ? '#888' : '#999'; + const btnBg = this.isDark ? '#16213e' : '#fafafa'; + const btnBorder = this.isDark ? '#0f3460' : '#d9d9d9'; + const btnColor = this.isDark ? '#aaa' : '#666'; + const shadow = this.isDark ? '0 2px 8px rgba(0,0,0,0.3)' : '0 1px 4px rgba(0,0,0,0.06)'; + + const shieldSvg = this._createShieldSvg('state-wait'); + + const btn = document.createElement('div'); + btn.style.cssText = 'display:flex;align-items:center;gap:10px;padding:12px 16px;border:1px solid ' + borderColor + ';border-radius:8px;cursor:pointer;user-select:none;transition:all 0.2s;font-size:14px;color:' + textColor + ';background:' + bgColor + ';box-shadow:' + shadow + ';'; + btn.innerHTML = + shieldSvg + + '点击进行验证' + + '' + typeLabel + '' + + '' + + '点击验证'; + + btn.addEventListener('mouseenter', () => { btn.style.borderColor = '#1890ff'; }); + btn.addEventListener('mouseleave', () => { btn.style.borderColor = borderColor; }); + btn.addEventListener('click', () => this._openCaptcha()); + + el.appendChild(btn); + this.triggerBtn = btn; + } + + _createShieldSvg(stateClass) { + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + + _setShieldState(state) { + if (!this.triggerBtn) return; + const svg = this.triggerBtn.querySelector('.tacaptcha-shield'); + if (svg) { + svg.classList.remove('state-wait', 'state-success', 'state-fail'); + const pulses = svg.querySelectorAll('.ward-pulse'); + pulses.forEach(p => { p.style.animation = 'none'; }); + void svg.offsetWidth; + svg.classList.add('state-' + state); + } + } + + _createModal() { + const panelBg = this.isDark ? '#1a1a2e' : '#fff'; + const textColor = this.isDark ? '#e0e0e0' : '#333'; + const subColor = this.isDark ? '#666' : '#999'; + const overlayBg = this.isDark ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.3)'; + const shadow = this.isDark ? '0 4px 20px rgba(0,0,0,0.4)' : '0 4px 20px rgba(0,0,0,0.15)'; + + this.modal = document.createElement('div'); + this.modal.style.cssText = 'display:none;position:fixed;top:0;left:0;width:100%;height:100%;z-index:10000;'; + + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;background:' + overlayBg + ';'; + overlay.addEventListener('click', () => this._closeCaptcha()); + + const panel = document.createElement('div'); + panel.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:' + panelBg + ';border-radius:8px;padding:20px;box-shadow:' + shadow + ';min-width:340px;'; + + this.captchaPanel = panel; + + const header = document.createElement('div'); + header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;'; + header.innerHTML = '请完成安全验证'; + + const refreshBtn = document.createElement('span'); + refreshBtn.textContent = '↻'; + refreshBtn.style.cssText = 'cursor:pointer;font-size:18px;color:' + subColor + ';'; + refreshBtn.addEventListener('click', () => this._loadCaptcha()); + header.appendChild(refreshBtn); + + const closeBtn = document.createElement('span'); + closeBtn.textContent = '✕'; + closeBtn.style.cssText = 'cursor:pointer;font-size:16px;color:' + subColor + ';margin-left:12px;'; + closeBtn.addEventListener('click', () => this._closeCaptcha()); + header.appendChild(closeBtn); + + this.captchaContent = document.createElement('div'); + this.captchaContent.style.cssText = 'position:relative;'; + + this.statusBar = document.createElement('div'); + this.statusBar.style.cssText = 'margin-top:12px;text-align:center;font-size:13px;color:' + subColor + ';'; + + panel.appendChild(header); + panel.appendChild(this.captchaContent); + panel.appendChild(this.statusBar); + + this.modal.appendChild(overlay); + this.modal.appendChild(panel); + document.body.appendChild(this.modal); + } + + async _openCaptcha() { + this.modal.style.display = 'block'; + if (this.options.onOpen) this.options.onOpen(); + await this._loadCaptcha(); + } + + _closeCaptcha() { + this.modal.style.display = 'none'; + if (this.options.onClose) this.options.onClose(); + } + + async _loadCaptcha() { + this.statusBar.textContent = '加载中...'; + this.captchaContent.innerHTML = ''; + this.verifyToken = null; + this._setShieldState('wait'); + + try { + const url = `${this.options.serverUrl}/challenge/generate`; + const params = new URLSearchParams(); + if (this.options.type) params.set('type', this.options.type); + params.set('scene', this.options.scene); + + const resp = await fetch(url + '?' + params.toString(), { + method: 'POST', + headers: { 'X-Site-Key': this.options.siteKey } + }); + const result = await resp.json(); + + if (result.code !== 200) { + this.statusBar.textContent = result.msg || '加载失败'; + if (this.options.onFail) this.options.onFail(result); + return; + } + + this.captchaData = result.data; + console.log('[ROTATE-INIT] degree=' + result.data.degree + ' type=' + result.data.type + ' tplW=' + result.data.templateImageWidth + ' tplH=' + result.data.templateImageHeight); + this._renderCaptcha(); + this.statusBar.textContent = '请完成验证'; + } catch (e) { + this.statusBar.textContent = '网络错误'; + if (this.options.onFail) this.options.onFail({ code: 10000, msg: e.message }); + } + } + + _isClickType(type) { + return type === 'WORD_IMAGE_CLICK' || type === 'ICON_CLICK' || type === 'WORD_ORDER_CLICK'; + } + + _renderCaptcha() { + this.captchaContent.innerHTML = ''; + const data = this.captchaData; + if (!data) return; + const isRotate = data.type === 'ROTATE'; + const isConcat = data.type === 'CONCAT'; + const isCurveDraw = data.type === 'CURVE_DRAW'; + const isScratch = data.type === 'SCRATCH'; + const isJigsaw = data.type === 'JIGSAW'; + const isPow = data.type === 'PROOF_OF_WORK'; + const isClick = this._isClickType(data.type); + + if (isClick) { + this._renderClickCaptcha(); + return; + } + + if (isCurveDraw) { + this._renderDrawCaptcha(); + return; + } + + if (isScratch) { + this._renderScratchCaptcha(); + return; + } + + if (isJigsaw) { + this._renderJigsawCaptcha(); + return; + } + + if (isPow) { + this._renderPowCaptcha(); + return; + } + + const bgImg = document.createElement('img'); + bgImg.src = data.backgroundImage; + bgImg.style.cssText = 'width:100%;display:block;border-radius:4px;'; + + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'position:relative;border-radius:4px;'; + wrapper.appendChild(bgImg); + + let tplImg = null; + let concatTopLayer = null; + let concatRandomY = 0; + if (isConcat) { + concatRandomY = (data.data && data.data.randomY) ? data.data.randomY : Math.round(data.backgroundImageHeight / 2); + wrapper.innerHTML = ''; + wrapper.style.cssText = 'position:relative;border-radius:4px;overflow:hidden;width:100%;height:180px;'; + const bgLayer = document.createElement('div'); + bgLayer.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;z-index:1;background-image:url(' + data.backgroundImage + ');background-size:100% 180px;background-position:0 0;background-repeat:no-repeat;'; + wrapper.appendChild(bgLayer); + const overlayHeight = ((data.backgroundImageHeight - concatRandomY) / data.backgroundImageHeight) * 180; + concatTopLayer = document.createElement('div'); + concatTopLayer.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:' + overlayHeight + 'px;z-index:2;overflow:hidden;background-image:url(' + data.backgroundImage + ');background-size:100% 180px;background-position:0 0;background-repeat:no-repeat;'; + wrapper.appendChild(concatTopLayer); + } else if (data.templateImage) { + tplImg = document.createElement('img'); + tplImg.src = data.templateImage; + if (isRotate) { + const holeLeft = ((data.backgroundImageWidth - data.templateImageWidth) / 2 / data.backgroundImageWidth * 100); + const holeTop = ((data.backgroundImageHeight - data.templateImageHeight) / 2 / data.backgroundImageHeight * 100); + tplImg.style.cssText = 'position:absolute;left:' + holeLeft + '%;top:' + holeTop + '%;width:' + (data.templateImageWidth / data.backgroundImageWidth * 100) + '%;pointer-events:none;transform:rotate(0deg);transform-origin:center center;z-index:1;'; + } else { + tplImg.style.cssText = 'position:absolute;top:0;left:0;height:100%;pointer-events:none;transition:none;'; + } + wrapper.appendChild(tplImg); + } + + this.captchaContent.appendChild(wrapper); + + const sliderBar = document.createElement('div'); + const barBg = this.isDark ? '#2a2a4a' : '#f0f0f0'; + sliderBar.style.cssText = 'position:relative;height:40px;width:100%;background:' + barBg + ';border-radius:4px;margin-top:8px;overflow:hidden;'; + + const sliderTrack = document.createElement('div'); + sliderTrack.style.cssText = 'position:absolute;top:0;left:0;height:100%;width:0;background:linear-gradient(90deg,#52c41a,#73d13d);border-radius:4px;transition:none;'; + + const sliderBtnBg = this.isDark ? '#1a1a2e' : '#fff'; + const sliderBtn = document.createElement('div'); + sliderBtn.style.cssText = 'position:absolute;top:2px;left:0;width:36px;height:36px;background:' + sliderBtnBg + ';border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,0.15);cursor:grab;display:flex;align-items:center;justify-content:center;font-size:16px;color:#999;transition:none;'; + sliderBtn.textContent = isRotate ? '↻' : '→'; + + sliderBar.appendChild(sliderTrack); + sliderBar.appendChild(sliderBtn); + this.captchaContent.appendChild(sliderBar); + + this.startX = 0; + this.currentX = 0; + this.trackList = []; + this.startTime = Date.now(); + + const getBarWidth = () => sliderBar.offsetWidth; + + const onMove = (e) => { + if (!this.isDragging) return; + const barWidth = getBarWidth(); + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const dx = clientX - this.startX; + const maxX = barWidth - 36; + this.currentX = Math.max(0, Math.min(dx, maxX)); + + sliderBtn.style.left = this.currentX + 'px'; + sliderTrack.style.width = (this.currentX + 18) + 'px'; + + const ratio = this.currentX / barWidth; + let scaledX; + if (isRotate && tplImg) { + const bgW = this.captchaData.backgroundImageWidth; + const cssDegree = (this.currentX / barWidth) * 360; + tplImg.style.transform = 'rotate(' + cssDegree + 'deg)'; + scaledX = Math.round(ratio * bgW); + } else if (isConcat && concatTopLayer) { + const moveX = ratio * wrapper.offsetWidth; + concatTopLayer.style.backgroundPositionX = moveX + 'px'; + scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth); + } else if (tplImg) { + tplImg.style.left = (ratio * 100) + '%'; + scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth); + } else { + scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth); + } + this.trackList.push({ + x: scaledX, + y: 0, + t: Date.now() - this.startTime, + type: 'MOVE' + }); + }; + + const onUp = async () => { + if (!this.isDragging) return; + this.isDragging = false; + sliderBtn.style.cursor = 'grab'; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onUp); + + const barWidth = getBarWidth(); + const ratio = this.currentX / barWidth; + const scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth); + this.trackList.push({ + x: scaledX, + y: 0, + t: Date.now() - this.startTime, + type: 'UP' + }); + + this.statusBar.textContent = '验证中...'; + this.statusBar.style.color = '#999'; + + await this._verify(); + }; + + sliderBtn.addEventListener('mousedown', (e) => { + this.isDragging = true; + this.startX = e.clientX - this.currentX; + this.trackList = [{ x: 0, y: 0, t: 0, type: 'DOWN' }]; + this.startTime = Date.now(); + sliderBtn.style.cursor = 'grabbing'; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + + sliderBtn.addEventListener('touchstart', (e) => { + this.isDragging = true; + this.startX = e.touches[0].clientX - this.currentX; + this.trackList = [{ x: 0, y: 0, t: 0, type: 'DOWN' }]; + this.startTime = Date.now(); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onUp); + }); + } + + _renderClickCaptcha() { + const data = this.captchaData; + const isWordOrder = data.type === 'WORD_ORDER_CLICK'; + const isIconClick = data.type === 'ICON_CLICK'; + const tipText = isWordOrder ? '请依次点击' : (isIconClick ? '请依次点击图中的图标' : '请依次点击图中文字'); + + const tipBar = document.createElement('div'); + tipBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;height:40px;margin-bottom:8px;'; + + const tipLabel = document.createElement('span'); + tipLabel.style.cssText = 'font-size:14px;color:' + (this.isDark ? '#e0e0e0' : '#333') + ';'; + tipLabel.textContent = tipText; + + tipBar.appendChild(tipLabel); + + if (data.templateImage) { + const tipImg = document.createElement('img'); + tipImg.src = data.templateImage; + tipImg.style.cssText = 'height:35px;max-width:200px;'; + tipBar.appendChild(tipImg); + } + + this.captchaContent.appendChild(tipBar); + + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'position:relative;border-radius:4px;overflow:hidden;cursor:crosshair;'; + + const bgImg = document.createElement('img'); + bgImg.src = data.backgroundImage; + bgImg.style.cssText = 'width:100%;display:block;border-radius:4px;'; + wrapper.appendChild(bgImg); + + const clickMask = document.createElement('div'); + clickMask.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;'; + wrapper.appendChild(clickMask); + + this.captchaContent.appendChild(wrapper); + + const confirmBtn = document.createElement('div'); + confirmBtn.style.cssText = 'width:100%;height:35px;border-radius:4px;background:linear-gradient(173deg,#e8a838 0%,#f0c060 100%);font-size:15px;text-align:center;line-height:35px;color:#fff;margin-top:8px;cursor:pointer;'; + confirmBtn.textContent = '确认'; + this.captchaContent.appendChild(confirmBtn); + + this.trackList = []; + this.startTime = Date.now(); + this.clickCount = 0; + this.clickMarkers = []; + + clickMask.addEventListener('click', (e) => { + if (e.target.className === 'tacaptcha-click-marker') return; + const rect = bgImg.getBoundingClientRect(); + const clickX = Math.round(e.clientX - rect.left); + const clickY = Math.round(e.clientY - rect.top); + const displayW = rect.width; + const displayH = rect.height; + const scaledX = Math.round((clickX / displayW) * data.backgroundImageWidth); + const scaledY = Math.round((clickY / displayH) * data.backgroundImageHeight); + + this.clickCount++; + if (this.clickCount === 1) { + this.startTime = Date.now(); + } + + this.trackList.push({ + x: scaledX, + y: scaledY, + t: Date.now() - this.startTime, + type: 'CLICK' + }); + + const marker = document.createElement('span'); + marker.className = 'tacaptcha-click-marker'; + marker.textContent = this.clickCount; + const left = clickX - 11; + const top = clickY - 11; + marker.style.cssText = 'position:absolute;left:' + left + 'px;top:' + top + 'px;border-radius:50%;background:#409eff;width:22px;height:22px;text-align:center;line-height:22px;color:#fff;border:2px solid #fff;box-sizing:content-box;font-size:12px;pointer-events:none;z-index:2;'; + clickMask.appendChild(marker); + this.clickMarkers.push(marker); + }); + + confirmBtn.addEventListener('click', async () => { + if (this.clickCount === 0) return; + this.statusBar.textContent = '验证中...'; + this.statusBar.style.color = '#999'; + await this._verify(); + }); + } + + _renderDrawCaptcha() { + const data = this.captchaData; + + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'position:relative;border-radius:4px;overflow:hidden;'; + + const bgImg = document.createElement('img'); + bgImg.src = data.backgroundImage; + bgImg.style.cssText = 'width:100%;display:block;border-radius:4px;'; + wrapper.appendChild(bgImg); + + const canvas = document.createElement('canvas'); + canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;touch-action:none;cursor:crosshair;'; + wrapper.appendChild(canvas); + + const tip = document.createElement('div'); + tip.textContent = '请在图上绘制曲线'; + tip.style.cssText = 'position:absolute;top:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.6);color:#fff;padding:4px 12px;border-radius:4px;font-size:13px;pointer-events:none;z-index:2;'; + wrapper.appendChild(tip); + + this.captchaContent.appendChild(wrapper); + + const confirmBtn = document.createElement('div'); + confirmBtn.style.cssText = 'width:100%;height:35px;border-radius:4px;background:linear-gradient(173deg,#e8a838 0%,#f0c060 100%);font-size:15px;text-align:center;line-height:35px;color:#fff;margin-top:8px;cursor:pointer;'; + confirmBtn.textContent = '确认'; + this.captchaContent.appendChild(confirmBtn); + + this.trackList = []; + this.startTime = Date.now(); + this.isDrawing = false; + + let ctx = null; + + const getPoint = (e) => { + const rect = bgImg.getBoundingClientRect(); + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const clientY = e.touches ? e.touches[0].clientY : e.clientY; + const x = clientX - rect.left; + const y = clientY - rect.top; + const scaledX = Math.round((x / rect.width) * data.backgroundImageWidth); + const scaledY = Math.round((y / rect.height) * data.backgroundImageHeight); + return { x, y, scaledX, scaledY }; + }; + + const startDraw = (e) => { + e.preventDefault(); + this.isDrawing = true; + const p = getPoint(e); + this.trackList.push({ x: p.scaledX, y: p.scaledY, t: 0, type: 'DOWN' }); + + const rect = wrapper.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + ctx = canvas.getContext('2d'); + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineWidth = 3; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.strokeStyle = 'rgba(64, 158, 255, 0.9)'; + tip.style.display = 'none'; + }; + + const moveDraw = (e) => { + if (!this.isDrawing) return; + e.preventDefault(); + const p = getPoint(e); + this.trackList.push({ x: p.scaledX, y: p.scaledY, t: Date.now() - this.startTime, type: 'MOVE' }); + if (ctx) { + ctx.lineTo(p.x, p.y); + ctx.stroke(); + } + }; + + const endDraw = (e) => { + if (!this.isDrawing) return; + this.isDrawing = false; + let clientX, clientY; + if (e.changedTouches && e.changedTouches.length > 0) { + clientX = e.changedTouches[0].clientX; + clientY = e.changedTouches[0].clientY; + } else { + clientX = e.clientX; + clientY = e.clientY; + } + const rect = bgImg.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + const scaledX = Math.round((x / rect.width) * data.backgroundImageWidth); + const scaledY = Math.round((y / rect.height) * data.backgroundImageHeight); + this.trackList.push({ x: scaledX, y: scaledY, t: Date.now() - this.startTime, type: 'UP' }); + }; + + canvas.addEventListener('mousedown', startDraw); + canvas.addEventListener('mousemove', moveDraw); + canvas.addEventListener('mouseup', endDraw); + canvas.addEventListener('touchstart', startDraw, { passive: false }); + canvas.addEventListener('touchmove', moveDraw, { passive: false }); + canvas.addEventListener('touchend', endDraw); + + confirmBtn.addEventListener('click', async () => { + if (this.trackList.length === 0) return; + this.statusBar.textContent = '验证中...'; + this.statusBar.style.color = '#999'; + await this._verify(); + }); + } + + _renderScratchCaptcha() { + const data = this.captchaData; + + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'position:relative;border-radius:4px;overflow:hidden;'; + + const bgImg = document.createElement('img'); + bgImg.src = data.backgroundImage; + bgImg.style.cssText = 'width:100%;display:block;border-radius:4px;'; + wrapper.appendChild(bgImg); + + // Cover layer canvas (the scratch-off overlay) + const coverCanvas = document.createElement('canvas'); + coverCanvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;touch-action:none;cursor:crosshair;'; + wrapper.appendChild(coverCanvas); + + const tip = document.createElement('div'); + tip.textContent = '刮开覆盖层'; + tip.style.cssText = 'position:absolute;top:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.6);color:#fff;padding:4px 12px;border-radius:4px;font-size:13px;pointer-events:none;z-index:2;'; + wrapper.appendChild(tip); + + this.captchaContent.appendChild(wrapper); + + const confirmBtn = document.createElement('div'); + confirmBtn.style.cssText = 'width:100%;height:35px;border-radius:4px;background:linear-gradient(173deg,#e8a838 0%,#f0c060 100%);font-size:15px;text-align:center;line-height:35px;color:#fff;margin-top:8px;cursor:pointer;'; + confirmBtn.textContent = '确认'; + this.captchaContent.appendChild(confirmBtn); + + this.trackList = []; + this.startTime = Date.now(); + this.scratchPercentage = 0; + + let isScratching = false; + let coverCtx = null; + + const initCanvas = () => { + const rect = wrapper.getBoundingClientRect(); + coverCanvas.width = rect.width; + coverCanvas.height = rect.height; + coverCtx = coverCanvas.getContext('2d'); + // Draw the cover layer using the template image + if (data.templateImage) { + const coverImg = new Image(); + coverImg.onload = () => { + coverCtx.drawImage(coverImg, 0, 0, rect.width, rect.height); + }; + coverImg.src = data.templateImage; + } else { + // Fallback: gray overlay + coverCtx.fillStyle = 'rgba(180, 180, 180, 0.75)'; + coverCtx.fillRect(0, 0, rect.width, rect.height); + } + }; + + // Use ResizeObserver or just init after append + requestAnimationFrame(initCanvas); + + const getPoint = (e) => { + const rect = bgImg.getBoundingClientRect(); + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const clientY = e.touches ? e.touches[0].clientY : e.clientY; + const x = clientX - rect.left; + const y = clientY - rect.top; + const scaledX = Math.round((x / rect.width) * data.backgroundImageWidth); + const scaledY = Math.round((y / rect.height) * data.backgroundImageHeight); + return { x, y, scaledX, scaledY }; + }; + + const scratchRadius = 20; + + const startScratch = (e) => { + e.preventDefault(); + isScratching = true; + tip.style.display = 'none'; + const p = getPoint(e); + this.trackList.push({ x: p.scaledX, y: p.scaledY, t: 0, type: 'DOWN' }); + if (coverCtx) { + coverCtx.globalCompositeOperation = 'destination-out'; + coverCtx.beginPath(); + coverCtx.arc(p.x, p.y, scratchRadius, 0, Math.PI * 2); + coverCtx.fill(); + } + }; + + const moveScratch = (e) => { + if (!isScratching) return; + e.preventDefault(); + const p = getPoint(e); + this.trackList.push({ x: p.scaledX, y: p.scaledY, t: Date.now() - this.startTime, type: 'MOVE' }); + if (coverCtx) { + coverCtx.globalCompositeOperation = 'destination-out'; + coverCtx.beginPath(); + coverCtx.arc(p.x, p.y, scratchRadius, 0, Math.PI * 2); + coverCtx.fill(); + } + }; + + const endScratch = (e) => { + if (!isScratching) return; + isScratching = false; + let clientX, clientY; + if (e.changedTouches && e.changedTouches.length > 0) { + clientX = e.changedTouches[0].clientX; + clientY = e.changedTouches[0].clientY; + } else { + clientX = e.clientX; + clientY = e.clientY; + } + const rect = bgImg.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + const scaledX = Math.round((x / rect.width) * data.backgroundImageWidth); + const scaledY = Math.round((y / rect.height) * data.backgroundImageHeight); + this.trackList.push({ x: scaledX, y: scaledY, t: Date.now() - this.startTime, type: 'UP' }); + + // Calculate scratch percentage + if (coverCtx) { + const imageData = coverCtx.getImageData(0, 0, coverCanvas.width, coverCanvas.height); + const pixels = imageData.data; + let transparent = 0; + let total = pixels.length / 4; + for (let i = 3; i < pixels.length; i += 4) { + if (pixels[i] === 0) transparent++; + } + this.scratchPercentage = transparent / total; + } + }; + + coverCanvas.addEventListener('mousedown', startScratch); + coverCanvas.addEventListener('mousemove', moveScratch); + coverCanvas.addEventListener('mouseup', endScratch); + coverCanvas.addEventListener('touchstart', startScratch, { passive: false }); + coverCanvas.addEventListener('touchmove', moveScratch, { passive: false }); + coverCanvas.addEventListener('touchend', endScratch); + + confirmBtn.addEventListener('click', async () => { + // Recalculate scratch percentage before submitting + if (coverCtx) { + const imageData = coverCtx.getImageData(0, 0, coverCanvas.width, coverCanvas.height); + const pixels = imageData.data; + let transparent = 0; + let total = pixels.length / 4; + for (let i = 3; i < pixels.length; i += 4) { + if (pixels[i] === 0) transparent++; + } + this.scratchPercentage = transparent / total; + } + this.statusBar.textContent = '验证中...'; + this.statusBar.style.color = '#999'; + await this._verify({ scratchPercentage: this.scratchPercentage }); + }); + } + + _renderJigsawCaptcha() { + const data = this.captchaData; + + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'position:relative;border-radius:4px;overflow:hidden;'; + + const bgImg = document.createElement('img'); + bgImg.src = data.backgroundImage; + bgImg.style.cssText = 'width:100%;display:block;border-radius:4px;'; + wrapper.appendChild(bgImg); + + const tip = document.createElement('div'); + tip.textContent = '点击方块还原图片'; + tip.style.cssText = 'position:absolute;top:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.6);color:#fff;padding:4px 12px;border-radius:4px;font-size:13px;pointer-events:none;z-index:2;'; + wrapper.appendChild(tip); + + this.captchaContent.appendChild(wrapper); + + const confirmBtn = document.createElement('div'); + confirmBtn.style.cssText = 'width:100%;height:35px;border-radius:4px;background:linear-gradient(173deg,#e8a838 0%,#f0c060 100%);font-size:15px;text-align:center;line-height:35px;color:#fff;margin-top:8px;cursor:pointer;'; + confirmBtn.textContent = '确认'; + this.captchaContent.appendChild(confirmBtn); + + // Jigsaw grid overlay + const gridCols = (data.data && data.data.cols) ? data.data.cols : 3; + const gridRows = (data.data && data.data.rows) ? data.data.rows : 2; + const totalTiles = gridCols * gridRows; + + // Current tile order (0..N-1 initially) + this.jigsawOrder = Array.from({ length: totalTiles }, (_, i) => i); + this.jigsawSelected = null; // index of first selected tile for swap + this.trackList = []; + this.startTime = Date.now(); + + const gridOverlay = document.createElement('div'); + gridOverlay.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;display:grid;grid-template-columns:repeat(' + gridCols + ',1fr);grid-template-rows:repeat(' + gridRows + ',1fr);z-index:1;'; + + for (let i = 0; i < totalTiles; i++) { + const tile = document.createElement('div'); + tile.dataset.index = i; + tile.style.cssText = 'border:1px solid rgba(255,255,255,0.3);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;color:rgba(255,255,255,0.7);transition:background 0.15s;'; + tile.textContent = (i + 1); + tile.addEventListener('click', () => { + if (this.jigsawSelected === null) { + this.jigsawSelected = i; + tile.style.background = 'rgba(64, 158, 255, 0.4)'; + } else { + // Swap tiles + const first = this.jigsawSelected; + const second = i; + if (first !== second) { + const tmp = this.jigsawOrder[first]; + this.jigsawOrder[first] = this.jigsawOrder[second]; + this.jigsawOrder[second] = tmp; + this.trackList.push({ x: first, y: second, t: Date.now() - this.startTime, type: 'SWAP' }); + } + // Reset selection + gridOverlay.children[first].style.background = ''; + this.jigsawSelected = null; + } + tip.style.display = 'none'; + }); + gridOverlay.appendChild(tile); + } + + wrapper.appendChild(gridOverlay); + + confirmBtn.addEventListener('click', async () => { + // Send the restore order as comma-separated string in data field + this.statusBar.textContent = '验证中...'; + this.statusBar.style.color = '#999'; + await this._verify({ jigsawOrder: this.jigsawOrder.join(',') }); + }); + } + + _renderPowCaptcha() { + const data = this.captchaData; + const challenge = (data.data && data.data.challenge) ? data.data.challenge : ''; + const difficulty = (data.data && data.data.difficulty) ? data.data.difficulty : 4; + + const container = document.createElement('div'); + container.style.cssText = 'padding:20px;text-align:center;'; + + const title = document.createElement('div'); + title.textContent = '计算验证'; + title.style.cssText = 'font-size:16px;font-weight:bold;margin-bottom:12px;color:' + (this.isDark ? '#e0e0e0' : '#333') + ';'; + container.appendChild(title); + + const challengeBox = document.createElement('div'); + challengeBox.style.cssText = 'background:' + (this.isDark ? '#2a2a4a' : '#f5f5f5') + ';border-radius:8px;padding:12px;margin-bottom:12px;font-family:monospace;font-size:13px;word-break:break-all;color:' + (this.isDark ? '#ccc' : '#666') + ';'; + challengeBox.textContent = challenge; + container.appendChild(challengeBox); + + const info = document.createElement('div'); + info.textContent = '难度: ' + difficulty + ' (需找到 ' + difficulty + ' 个前导零的 SHA-256)'; + info.style.cssText = 'font-size:12px;color:#999;margin-bottom:12px;'; + container.appendChild(info); + + const nonceInput = document.createElement('input'); + nonceInput.type = 'text'; + nonceInput.placeholder = '输入 nonce 值'; + nonceInput.style.cssText = 'width:100%;padding:8px 12px;border:1px solid #d9d9d9;border-radius:4px;font-size:14px;box-sizing:border-box;margin-bottom:8px;'; + container.appendChild(nonceInput); + + const computeBtn = document.createElement('div'); + computeBtn.style.cssText = 'width:100%;height:35px;border-radius:4px;background:linear-gradient(173deg,#409eff 0%,#66b1ff 100%);font-size:14px;text-align:center;line-height:35px;color:#fff;cursor:pointer;margin-bottom:8px;'; + computeBtn.textContent = '自动计算'; + container.appendChild(computeBtn); + + const hashResult = document.createElement('div'); + hashResult.style.cssText = 'font-family:monospace;font-size:11px;color:#999;word-break:break-all;min-height:20px;margin-bottom:8px;'; + container.appendChild(hashResult); + + this.captchaContent.appendChild(container); + + this.trackList = [{ x: 0, y: 0, t: 0, type: 'POW' }]; + this.startTime = Date.now(); + this.powNonce = ''; + + // Auto-compute function + const computePow = () => { + computeBtn.textContent = '计算中...'; + hashResult.textContent = '正在寻找 nonce...'; + const prefix = '0'.repeat(difficulty); + + // Use Web Worker for non-blocking computation + const workerCode = ` + self.onmessage = function(e) { + const challenge = e.data.challenge; + const difficulty = e.data.difficulty; + const prefix = '0'.repeat(difficulty); + let nonce = 0; + const maxIterations = 5000000; + while (nonce < maxIterations) { + const input = challenge + nonce; + const hash = sha256(input); + if (hash.substring(0, difficulty) === prefix) { + self.postMessage({ nonce: nonce, hash: hash, found: true }); + return; + } + nonce++; + } + self.postMessage({ nonce: nonce, found: false }); + }; + function sha256(str) { + function rightRotate(value, amount) { return (value >>> amount) | (value << (32 - amount)); } + var mathPow = Math.pow; + var maxWord = mathPow(2, 32); + var lengthProperty = 'length'; + var i, j; + var result = ''; + var words = []; + var asciiBitLength = str[lengthProperty] * 8; + var hash = []; + var k = []; + var primeCounter = 0; + var isComposite = {}; + for (var candidate = 2; primeCounter < 64; candidate++) { + if (!isComposite[candidate]) { + for (i = 0; i < 313; i += candidate) isComposite[i] = candidate; + k[primeCounter++] = (mathPow(candidate, .5) * maxWord) | 0; + } + } + str += '\\x80'; + while (str[lengthProperty] % 64 - 56) str += '\\x00'; + for (i = 0; i < str[lengthProperty]; i++) { + j = str.charCodeAt(i); + if (j >> 8) return; + words[i >> 2] |= j << ((3 - i) % 4) * 8; + } + words[words[lengthProperty]] = ((asciiBitLength / maxWord) | 0); + words[words[lengthProperty]] = (asciiBitLength); + for (j = 0; j < words[lengthProperty];) { + var w = words.slice(j, j += 16); + var oldHash = hash; + hash = hash.slice(0, 8); + for (i = 0; i < 64; i++) { + var w15 = w[i - 15], w2 = w[i - 2]; + var a = hash[0], e = hash[4]; + var temp1 = hash[7] + (rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25)) + + ((e & hash[5]) ^ (~e & hash[6])) + k[i] + + (w[i] = (i < 16) ? w[i] : ( + w[i - 16] + (rightRotate(w15, 7) ^ rightRotate(w15, 18) ^ (w15 >>> 3)) + + w[i - 7] + (rightRotate(w2, 17) ^ rightRotate(w2, 19) ^ (w2 >>> 10)) + ) | 0); + var temp2 = (rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22)) + + ((a & hash[1]) ^ (a & hash[2]) ^ (hash[1] & hash[2])); + hash = [(temp1 + temp2) | 0].concat(hash); + hash[4] = (hash[4] + temp1) | 0; + } + for (i = 0; i < 8; i++) hash[i] = (hash[i] + oldHash[i]) | 0; + } + for (i = 0; i < 8; i++) + for (j = 3; j + 1; j--) + result += ((hash[i] >> (j * 8)) & 255).toString(16).padStart(2, '0'); + return result; + } + `; + const blob = new Blob([workerCode], { type: 'application/javascript' }); + const worker = new Worker(URL.createObjectURL(blob)); + worker.onmessage = (e) => { + if (e.data.found) { + this.powNonce = String(e.data.nonce); + nonceInput.value = this.powNonce; + hashResult.textContent = 'SHA-256: ' + e.data.hash; + computeBtn.textContent = '已找到 nonce!'; + computeBtn.style.background = 'linear-gradient(173deg,#52c41a 0%,#73d13d 100%)'; + } else { + hashResult.textContent = '未找到,请重试...'; + computeBtn.textContent = '自动计算'; + } + worker.terminate(); + }; + worker.postMessage({ challenge: challenge, difficulty: difficulty }); + }; + + computeBtn.addEventListener('click', computePow); + + // Override verify for PoW + const originalVerify = this._verify.bind(this); + this._verifyPow = async () => { + if (!this.captchaData) return; + this.statusBar.textContent = '验证中...'; + const nonce = nonceInput.value.trim(); + if (!nonce) { + this.statusBar.textContent = '请输入或计算 nonce'; + this.statusBar.style.color = '#ff4d4f'; + return; + } + try { + const trackData = { + bgImageWidth: 0, + bgImageHeight: 0, + templateImageWidth: 0, + templateImageHeight: 0, + startTime: this.startTime, + stopTime: Date.now(), + trackList: this.trackList, + data: nonce + }; + const resp = await fetch(`${this.options.serverUrl}/challenge/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Site-Key': this.options.siteKey + }, + body: JSON.stringify({ + id: this.captchaData.id, + data: trackData + }) + }); + const result = await resp.json(); + if (result.code === 200 && result.data && result.data.verifyToken) { + this.verifyToken = result.data.verifyToken; + this.statusBar.textContent = '验证成功'; + this.statusBar.style.color = '#52c41a'; + if (this.options.onSuccess) { + this.options.onSuccess({ code: 200, msg: 'OK', data: { verifyToken: this.verifyToken } }); + } + } else { + this.statusBar.textContent = result.msg || '验证失败'; + this.statusBar.style.color = '#ff4d4f'; + if (this.options.onFail) { + this.options.onFail(result); + } + } + } catch (err) { + this.statusBar.textContent = '网络错误'; + this.statusBar.style.color = '#ff4d4f'; + } + }; + } + + async _verify(extraData) { + if (!this.captchaData) return; + this.statusBar.textContent = '验证中...'; + + // Handle PROOF_OF_WORK separately + if (this.captchaData.type === 'PROOF_OF_WORK' && this._verifyPow) { + await this._verifyPow(); + return; + } + + try { + const trackData = { + bgImageWidth: this.captchaData.backgroundImageWidth, + bgImageHeight: this.captchaData.backgroundImageHeight, + templateImageWidth: this.captchaData.templateImageWidth, + templateImageHeight: this.captchaData.templateImageHeight, + startTime: this.startTime, + stopTime: Date.now(), + trackList: this.trackList + }; + + // Pass extra data for SCRATCH and JIGSAW + if (extraData) { + if (extraData.scratchPercentage !== undefined) { + trackData.data = String(Math.round(extraData.scratchPercentage * 100)); + } else if (extraData.jigsawOrder !== undefined) { + trackData.data = extraData.jigsawOrder; + } + } + + const resp = await fetch(`${this.options.serverUrl}/challenge/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Site-Key': this.options.siteKey + }, + body: JSON.stringify({ + id: this.captchaData.id, + data: trackData + }) + }); + const result = await resp.json(); + + if (result.code === 200 && result.data && result.data.verifyToken) { + this.verifyToken = result.data.verifyToken; + this.statusBar.textContent = '验证成功'; + this.statusBar.style.color = '#52c41a'; + if (this.options.onSuccess) { + this.options.onSuccess({ + code: 200, + msg: '操作成功', + data: { verifyToken: this.verifyToken } + }); + } + setTimeout(() => { + this._closeCaptcha(); + this._setShieldState('success'); + }, 400); + } else { + this._setShieldState('fail'); + this.statusBar.textContent = '验证失败,请重试'; + this.statusBar.style.color = '#ff4d4f'; + if (this.options.onFail) this.options.onFail(result); + setTimeout(() => this._loadCaptcha(), 1500); + } + } catch (e) { + this.statusBar.textContent = '网络错误'; + if (this.options.onFail) this.options.onFail({ code: 10000, msg: e.message }); + } + } + + async show() { + await this._openCaptcha(); + } + + destroy() { + if (this.modal && this.modal.parentNode) { + this.modal.parentNode.removeChild(this.modal); + } + if (this.options.elId) { + INSTANCES.delete(this.options.elId); + } + } + } + + const TACaptcha = { + async init(options) { + const elId = options.elId || ('_tac_' + Date.now()); + if (INSTANCES.has(elId)) { + INSTANCES.get(elId).destroy(); + } + const instance = new TACaptchaInstance(options); + INSTANCES.set(elId, instance); + return instance; + }, + + refresh(elId) { + const inst = INSTANCES.get(elId); + if (inst) inst._loadCaptcha(); + }, + + show(elId) { + const inst = INSTANCES.get(elId); + if (inst) return inst.show(); + return Promise.reject(new Error('Instance not found')); + } + }; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = TACaptcha; + } else { + global.TACaptcha = TACaptcha; + } +})(typeof window !== 'undefined' ? window : this); diff --git a/tianai-captcha-web-sdk/package-lock.json b/tianai-captcha-web-sdk/package-lock.json index 2a405e6..3b4a743 100644 --- a/tianai-captcha-web-sdk/package-lock.json +++ b/tianai-captcha-web-sdk/package-lock.json @@ -8,6 +8,9 @@ "name": "webpack-demo", "version": "1.0.0", "license": "ISC", + "dependencies": { + "sass": "^1.103.1" + }, "devDependencies": { "@babel/core": "^7.22.17", "@babel/preset-env": "^7.22.15", @@ -1904,6 +1907,312 @@ "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", "dev": true }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.23", "resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.23.tgz", @@ -3284,6 +3593,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz", @@ -4300,12 +4619,10 @@ } }, "node_modules/immutable": { - "version": "4.3.6", - "resolved": "https://registry.npmmirror.com/immutable/-/immutable-4.3.6.tgz", - "integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==", - "dev": true, - "optional": true, - "peer": true + "version": "5.1.9", + "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "license": "MIT" }, "node_modules/import-local": { "version": "3.1.0", @@ -4428,7 +4745,7 @@ "version": "2.1.1", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -4449,7 +4766,7 @@ "version": "4.0.3", "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -5224,6 +5541,13 @@ "tslib": "^2.0.3" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-forge": { "version": "1.3.1", "resolved": "https://registry.npmmirror.com/node-forge/-/node-forge-1.3.1.tgz", @@ -6021,22 +6345,23 @@ "dev": true }, "node_modules/sass": { - "version": "1.77.4", - "resolved": "https://registry.npmmirror.com/sass/-/sass-1.77.4.tgz", - "integrity": "sha512-vcF3Ckow6g939GMA4PeU7b2K/9FALXk2KF9J87txdHzXbUF9XRQRwSxcAs/fGaTnJeBFd7UoV22j3lzMLdM0Pw==", - "dev": true, - "optional": true, - "peer": true, + "version": "1.103.1", + "resolved": "https://registry.npmmirror.com/sass/-/sass-1.103.1.tgz", + "integrity": "sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==", + "license": "MIT", "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", + "chokidar": "^5.0.0", + "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { "sass": "sass.js" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" } }, "node_modules/sass-loader": { @@ -6072,6 +6397,34 @@ } } }, + "node_modules/sass/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-3.3.0.tgz", @@ -6336,7 +6689,6 @@ "version": "1.0.2", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -8624,6 +8976,110 @@ "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", "dev": true }, + "@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "optional": true, + "requires": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0", + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "dependencies": { + "picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "optional": true + } + } + }, + "@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "optional": true + }, + "@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "optional": true + }, + "@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "optional": true + }, + "@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "optional": true + }, + "@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "optional": true + }, + "@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "optional": true + }, + "@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "optional": true + }, + "@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "optional": true + }, + "@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "optional": true + }, + "@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "optional": true + }, + "@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "optional": true + }, + "@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "optional": true + }, "@polka/url": { "version": "1.0.0-next.23", "resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.23.tgz", @@ -9792,6 +10248,12 @@ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "dev": true }, + "detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "optional": true + }, "detect-node": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz", @@ -10608,12 +11070,9 @@ "requires": {} }, "immutable": { - "version": "4.3.6", - "resolved": "https://registry.npmmirror.com/immutable/-/immutable-4.3.6.tgz", - "integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==", - "dev": true, - "optional": true, - "peer": true + "version": "5.1.9", + "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==" }, "import-local": { "version": "3.1.0", @@ -10709,7 +11168,7 @@ "version": "2.1.1", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true + "devOptional": true }, "is-generator-function": { "version": "1.0.10", @@ -10724,7 +11183,7 @@ "version": "4.0.3", "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "requires": { "is-extglob": "^2.1.1" } @@ -11330,6 +11789,12 @@ "tslib": "^2.0.3" } }, + "node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "optional": true + }, "node-forge": { "version": "1.3.1", "resolved": "https://registry.npmmirror.com/node-forge/-/node-forge-1.3.1.tgz", @@ -11954,16 +12419,29 @@ "dev": true }, "sass": { - "version": "1.77.4", - "resolved": "https://registry.npmmirror.com/sass/-/sass-1.77.4.tgz", - "integrity": "sha512-vcF3Ckow6g939GMA4PeU7b2K/9FALXk2KF9J87txdHzXbUF9XRQRwSxcAs/fGaTnJeBFd7UoV22j3lzMLdM0Pw==", - "dev": true, - "optional": true, - "peer": true, + "version": "1.103.1", + "resolved": "https://registry.npmmirror.com/sass/-/sass-1.103.1.tgz", + "integrity": "sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==", "requires": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", + "@parcel/watcher": "^2.4.1", + "chokidar": "^5.0.0", + "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" + }, + "dependencies": { + "chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "requires": { + "readdirp": "^5.0.0" + } + }, + "readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==" + } } }, "sass-loader": { @@ -12197,8 +12675,7 @@ "source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" }, "source-map-support": { "version": "0.5.21", diff --git a/tianai-captcha-web-sdk/package.json b/tianai-captcha-web-sdk/package.json index e34a184..2f64056 100644 --- a/tianai-captcha-web-sdk/package.json +++ b/tianai-captcha-web-sdk/package.json @@ -29,5 +29,8 @@ "webpack-bundle-analyzer": "^4.9.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1" + }, + "dependencies": { + "sass": "^1.103.1" } } diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/AesEncryptor.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/AesEncryptor.java new file mode 100644 index 0000000..6380f5a --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/AesEncryptor.java @@ -0,0 +1,71 @@ +package cloud.tianai.captcha.crypto; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.security.SecureRandom; +import java.util.Base64; + +public class AesEncryptor { + + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_IV_LENGTH = 12; + private static final int GCM_TAG_LENGTH = 128; + private final SecureRandom secureRandom = new SecureRandom(); + + private final SecretKeySpec key; + + public AesEncryptor(byte[] keyBytes) { + if (keyBytes.length != 32) { + throw new IllegalArgumentException("AES-256 key must be 32 bytes, got " + keyBytes.length); + } + this.key = new SecretKeySpec(keyBytes, "AES"); + } + + public AesEncryptor(String base64Key) { + this(Base64.getDecoder().decode(base64Key)); + } + + public String encrypt(String plaintext) { + try { + byte[] iv = new byte[GCM_IV_LENGTH]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec); + byte[] ciphertext = cipher.doFinal(plaintext.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + byte[] combined = new byte[iv.length + ciphertext.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length); + return Base64.getEncoder().encodeToString(combined); + } catch (Exception e) { + throw new CryptoException("AES encryption failed", e); + } + } + + public String decrypt(String encrypted) { + try { + byte[] combined = Base64.getDecoder().decode(encrypted); + if (combined.length < GCM_IV_LENGTH) { + throw new CryptoException("Encrypted data too short"); + } + byte[] iv = new byte[GCM_IV_LENGTH]; + System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH); + byte[] ciphertext = new byte[combined.length - GCM_IV_LENGTH]; + System.arraycopy(combined, GCM_IV_LENGTH, ciphertext, 0, ciphertext.length); + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec); + byte[] plaintext = cipher.doFinal(ciphertext); + return new String(plaintext, java.nio.charset.StandardCharsets.UTF_8); + } catch (Exception e) { + throw new CryptoException("AES decryption failed", e); + } + } + + public static byte[] generateKey() { + byte[] key = new byte[32]; + new SecureRandom().nextBytes(key); + return key; + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/CryptoException.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/CryptoException.java new file mode 100644 index 0000000..d30e2da --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/CryptoException.java @@ -0,0 +1,12 @@ +package cloud.tianai.captcha.crypto; + +public class CryptoException extends RuntimeException { + + public CryptoException(String message) { + super(message); + } + + public CryptoException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/CryptoService.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/CryptoService.java new file mode 100644 index 0000000..03bb051 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/CryptoService.java @@ -0,0 +1,115 @@ +package cloud.tianai.captcha.crypto; + +import java.security.KeyPair; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +public class CryptoService { + + private final AesEncryptor aesEncryptor; + private final RsaEncryptor rsaEncryptor; + private final Signer signer; + private final String secretKey; + + public CryptoService(byte[] aesKey, KeyPair rsaKeyPair, String secretKey) { + this.aesEncryptor = new AesEncryptor(aesKey); + try { + this.rsaEncryptor = new RsaEncryptor( + RsaEncryptor.publicKeyToBase64(rsaKeyPair.getPublic()), + RsaEncryptor.privateKeyToBase64(rsaKeyPair.getPrivate()) + ); + } catch (Exception e) { + throw new CryptoException("Failed to initialize RSA encryptor", e); + } + this.signer = new Signer(); + this.secretKey = secretKey; + } + + public CryptoService(String base64AesKey, String base64PublicKey, String base64PrivateKey, String secretKey) { + this.aesEncryptor = new AesEncryptor(base64AesKey); + try { + this.rsaEncryptor = new RsaEncryptor(base64PublicKey, base64PrivateKey); + } catch (Exception e) { + throw new CryptoException("Failed to initialize RSA encryptor", e); + } + this.signer = new Signer(); + this.secretKey = secretKey; + } + + public EncryptedPayload encrypt(String plaintext) { + String nonce = signer.generateNonce(); + long timestamp = System.currentTimeMillis(); + + String aesEncrypted = aesEncryptor.encrypt(plaintext); + String signature = signer.signWithTimestamp(aesEncrypted + "|" + nonce, secretKey, timestamp); + + EncryptedPayload payload = new EncryptedPayload(); + payload.data = aesEncrypted; + payload.nonce = nonce; + payload.timestamp = timestamp; + payload.signature = signature; + return payload; + } + + public String decrypt(EncryptedPayload payload) { + if (!signer.verifyWithTimestamp( + payload.data + "|" + payload.nonce, + secretKey, + payload.signature, + payload.timestamp, + 300000 + )) { + throw new CryptoException("Signature verification failed or timestamp expired"); + } + return aesEncryptor.decrypt(payload.data); + } + + public String encryptAesKeyForTransport(byte[] aesKey) { + return rsaEncryptor.encrypt(Base64.getEncoder().encodeToString(aesKey)); + } + + public String decryptAesKeyFromTransport(String encryptedAesKey) { + return rsaEncryptor.decrypt(encryptedAesKey); + } + + public String getPublicKeyBase64() { + return RsaEncryptor.publicKeyToBase64(rsaEncryptor.getPublicKey()); + } + + public static CryptoService generate() throws Exception { + byte[] aesKey = AesEncryptor.generateKey(); + KeyPair rsaKeyPair = RsaEncryptor.generateKeyPair(); + byte[] secretKeyBytes = new byte[32]; + new java.security.SecureRandom().nextBytes(secretKeyBytes); + String secretKey = Base64.getEncoder().encodeToString(secretKeyBytes); + return new CryptoService(aesKey, rsaKeyPair, secretKey); + } + + public static class EncryptedPayload { + public String data; + public String nonce; + public long timestamp; + public String signature; + + public Map toMap() { + Map map = new HashMap<>(); + map.put("data", data); + map.put("nonce", nonce); + map.put("ts", timestamp); + map.put("sig", signature); + return map; + } + + public static EncryptedPayload fromMap(Map map) { + EncryptedPayload payload = new EncryptedPayload(); + payload.data = (String) map.get("data"); + payload.nonce = (String) map.get("nonce"); + payload.timestamp = map.get("ts") instanceof Number + ? ((Number) map.get("ts")).longValue() + : Long.parseLong(String.valueOf(map.get("ts"))); + payload.signature = (String) map.get("sig"); + return payload; + } + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/RsaEncryptor.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/RsaEncryptor.java new file mode 100644 index 0000000..cbabeb4 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/RsaEncryptor.java @@ -0,0 +1,95 @@ +package cloud.tianai.captcha.crypto; + +import javax.crypto.Cipher; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import javax.crypto.spec.OAEPParameterSpec; +import javax.crypto.spec.PSource; +import java.util.Base64; + +public class RsaEncryptor { + + private static final String ALGORITHM = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"; + private static final int KEY_SIZE = 4096; + private static final int MAX_ENCRYPT_BLOCK = 446; + + private final PublicKey publicKey; + private final PrivateKey privateKey; + + public PublicKey getPublicKey() { + return publicKey; + } + + public PrivateKey getPrivateKey() { + return privateKey; + } + + public RsaEncryptor(PublicKey publicKey, PrivateKey privateKey) { + this.publicKey = publicKey; + this.privateKey = privateKey; + } + + public RsaEncryptor(String base64PublicKey, String base64PrivateKey) throws Exception { + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + this.publicKey = base64PublicKey != null + ? keyFactory.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(base64PublicKey))) + : null; + this.privateKey = base64PrivateKey != null + ? keyFactory.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(base64PrivateKey))) + : null; + } + + public String encrypt(String plaintext) { + if (publicKey == null) { + throw new CryptoException("Public key not available for encryption"); + } + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + OAEPParameterSpec oaepSpec = new OAEPParameterSpec( + "SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT + ); + cipher.init(Cipher.ENCRYPT_MODE, publicKey, oaepSpec); + byte[] encrypted = cipher.doFinal(plaintext.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encrypted); + } catch (Exception e) { + throw new CryptoException("RSA encryption failed", e); + } + } + + public String decrypt(String encrypted) { + if (privateKey == null) { + throw new CryptoException("Private key not available for decryption"); + } + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + OAEPParameterSpec oaepSpec = new OAEPParameterSpec( + "SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT + ); + cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepSpec); + byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted)); + return new String(decrypted, java.nio.charset.StandardCharsets.UTF_8); + } catch (Exception e) { + throw new CryptoException("RSA decryption failed", e); + } + } + + public static KeyPair generateKeyPair() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(KEY_SIZE); + return generator.generateKeyPair(); + } + + public static String publicKeyToBase64(PublicKey key) { + return Base64.getEncoder().encodeToString(key.getEncoded()); + } + + public static String privateKeyToBase64(PrivateKey key) { + return Base64.getEncoder().encodeToString(key.getEncoded()); + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/Signer.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/Signer.java new file mode 100644 index 0000000..3f95eb8 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/crypto/Signer.java @@ -0,0 +1,57 @@ +package cloud.tianai.captcha.crypto; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +public class Signer { + + private static final String ALGORITHM = "SHA-256"; + private final SecureRandom secureRandom = new SecureRandom(); + + public String sign(String data, String secretKey) { + try { + MessageDigest digest = MessageDigest.getInstance(ALGORITHM); + String payload = secretKey + data + secretKey; + byte[] hash = digest.digest(payload.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hash); + } catch (Exception e) { + throw new CryptoException("Signing failed", e); + } + } + + public boolean verify(String data, String secretKey, String signature) { + String expected = sign(data, secretKey); + return constantTimeEquals(expected, signature); + } + + public String signWithTimestamp(String data, String secretKey, long timestamp) { + return sign(data + "|" + timestamp, secretKey); + } + + public boolean verifyWithTimestamp(String data, String secretKey, String signature, long timestamp, long toleranceMs) { + long now = System.currentTimeMillis(); + if (Math.abs(now - timestamp) > toleranceMs) { + return false; + } + return verify(data + "|" + timestamp, secretKey, signature); + } + + public String generateNonce() { + byte[] nonce = new byte[16]; + secureRandom.nextBytes(nonce); + return Base64.getUrlEncoder().withoutPadding().encodeToString(nonce); + } + + private boolean constantTimeEquals(String a, String b) { + if (a.length() != b.length()) { + return false; + } + int result = 0; + for (int i = 0; i < a.length(); i++) { + result |= a.charAt(i) ^ b.charAt(i); + } + return result == 0; + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/AngleRotateImageCaptchaGenerator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/AngleRotateImageCaptchaGenerator.java new file mode 100644 index 0000000..68c0571 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/AngleRotateImageCaptchaGenerator.java @@ -0,0 +1,116 @@ +package cloud.tianai.captcha.generator.impl; + +import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator; +import cloud.tianai.captcha.generator.ImageTransform; +import cloud.tianai.captcha.generator.common.model.dto.*; +import cloud.tianai.captcha.generator.common.util.CaptchaImageUtils; +import cloud.tianai.captcha.interceptor.CaptchaInterceptor; +import cloud.tianai.captcha.resource.ImageCaptchaResourceManager; +import cloud.tianai.captcha.resource.common.model.dto.Resource; +import cloud.tianai.captcha.resource.common.model.dto.ResourceMap; + +import java.awt.*; +import java.awt.geom.AffineTransform; +import java.awt.image.BufferedImage; + +public class AngleRotateImageCaptchaGenerator extends AbstractImageCaptchaGenerator { + + public static String TEMPLATE_ACTIVE_IMAGE_NAME = "active.png"; + public static String TEMPLATE_FIXED_IMAGE_NAME = "fixed.png"; + + public AngleRotateImageCaptchaGenerator(ImageCaptchaResourceManager rm) { + super(rm); + } + + public AngleRotateImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform) { + super(rm); + setImageTransform(transform); + } + + public AngleRotateImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform, CaptchaInterceptor interceptor) { + super(rm); + setImageTransform(transform); + setInterceptor(interceptor); + } + + @Override + protected void doInit() {} + + @Override + public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) { + GenerateParam param = captchaExchange.getParam(); + ResourceMap templateResource = requiredRandomGetTemplate(param.getType(), param.getTemplateImageTag()); + Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag()); + BufferedImage background = getResourceImage(resourceImage); + BufferedImage activeTemplate = getTemplateImage(templateResource, TEMPLATE_ACTIVE_IMAGE_NAME); + BufferedImage fixedTemplate = getTemplateImage(templateResource, TEMPLATE_FIXED_IMAGE_NAME); + + int centerX = background.getWidth() / 2; + int centerY = background.getHeight() / 2; + + int targetAngle = randomInt(30, 330); + double radians = Math.toRadians(targetAngle); + + BufferedImage rotatedActive = rotateImage(activeTemplate, -radians); + + int templateSize = activeTemplate.getWidth(); + int drawX = centerX - templateSize / 2; + int drawY = centerY - templateSize / 2; + + CaptchaImageUtils.overlayImage(background, fixedTemplate, drawX, drawY); + + BufferedImage matrixTemplate = CaptchaImageUtils.createTransparentImage(templateSize, background.getHeight()); + CaptchaImageUtils.overlayImage(matrixTemplate, rotatedActive, 0, drawY); + + captchaExchange.setBackgroundImage(background); + captchaExchange.setTemplateImage(matrixTemplate); + captchaExchange.setTemplateResource(templateResource); + captchaExchange.setResourceImage(resourceImage); + captchaExchange.setTransferData(new AngleData(targetAngle)); + } + + @Override + public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) { + GenerateParam param = captchaExchange.getParam(); + BufferedImage backgroundImage = captchaExchange.getBackgroundImage(); + BufferedImage templateImage = captchaExchange.getTemplateImage(); + Resource resourceImage = captchaExchange.getResourceImage(); + ResourceMap templateResource = captchaExchange.getTemplateResource(); + CustomData customData = captchaExchange.getCustomData(); + AngleData angleData = (AngleData) captchaExchange.getTransferData(); + ImageTransformData transform = getImageTransform().transform(param, backgroundImage, templateImage, resourceImage, templateResource, customData); + + RotateImageCaptchaInfo info = RotateImageCaptchaInfo.of((double) angleData.angle, 0, + transform.getBackgroundImageUrl(), transform.getTemplateImageUrl(), + resourceImage.getTag(), templateResource.getTag(), + backgroundImage.getWidth(), backgroundImage.getHeight(), + templateImage.getWidth(), templateImage.getHeight()); + info.setData(customData); + return info; + } + + private BufferedImage rotateImage(BufferedImage image, double radians) { + int w = image.getWidth(); + int h = image.getHeight(); + int newW = (int) Math.ceil(Math.abs(w * Math.cos(radians)) + Math.abs(h * Math.sin(radians))); + int newH = (int) Math.ceil(Math.abs(h * Math.cos(radians)) + Math.abs(w * Math.sin(radians))); + BufferedImage result = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = result.createGraphics(); + try { + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + AffineTransform at = new AffineTransform(); + at.translate((double) newW / 2, (double) newH / 2); + at.rotate(radians); + at.translate((double) -w / 2, (double) -h / 2); + g2d.drawRenderedImage(image, at); + } finally { + g2d.dispose(); + } + return result; + } + + public static class AngleData { + public int angle; + public AngleData(int angle) { this.angle = angle; } + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CurveDrawImageCaptchaGenerator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CurveDrawImageCaptchaGenerator.java new file mode 100644 index 0000000..743875e --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CurveDrawImageCaptchaGenerator.java @@ -0,0 +1,117 @@ +package cloud.tianai.captcha.generator.impl; + +import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator; +import cloud.tianai.captcha.generator.ImageTransform; +import cloud.tianai.captcha.generator.common.model.dto.*; +import cloud.tianai.captcha.interceptor.CaptchaInterceptor; +import cloud.tianai.captcha.resource.ImageCaptchaResourceManager; +import cloud.tianai.captcha.resource.common.model.dto.Resource; + +import java.awt.*; +import java.awt.geom.CubicCurve2D; +import java.awt.geom.Point2D; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +public class CurveDrawImageCaptchaGenerator extends AbstractImageCaptchaGenerator { + + public CurveDrawImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); } + public CurveDrawImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); } + public CurveDrawImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); } + + @Override + protected void doInit() {} + + @Override + public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) { + GenerateParam param = captchaExchange.getParam(); + Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag()); + BufferedImage background = getResourceImage(resourceImage); + int width = background.getWidth(); + int height = background.getHeight(); + + ThreadLocalRandom random = ThreadLocalRandom.current(); + double x0 = random.nextDouble(width * 0.05, width * 0.15); + double y0 = random.nextDouble(height * 0.3, height * 0.7); + double x3 = random.nextDouble(width * 0.85, width * 0.95); + double y3 = random.nextDouble(height * 0.3, height * 0.7); + double x1 = random.nextDouble(width * 0.25, width * 0.45); + double y1 = random.nextDouble(height * 0.1, height * 0.9); + double x2 = random.nextDouble(width * 0.55, width * 0.75); + double y2 = random.nextDouble(height * 0.1, height * 0.9); + + Graphics2D g2d = background.createGraphics(); + try { + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.setColor(new Color(50, 50, 200, 180)); + g2d.setStroke(new BasicStroke(3)); + CubicCurve2D curve = new CubicCurve2D.Double(x0, y0, x1, y1, x2, y2, x3, y3); + g2d.draw(curve); + g2d.setColor(new Color(200, 50, 50, 200)); + g2d.fillOval((int) x0 - 5, (int) y0 - 5, 10, 10); + g2d.fillOval((int) x3 - 5, (int) y3 - 5, 10, 10); + } finally { + g2d.dispose(); + } + + captchaExchange.setBackgroundImage(background); + captchaExchange.setTemplateImage(null); + captchaExchange.setResourceImage(resourceImage); + CurveDrawData curveData = new CurveDrawData(x0, y0, x1, y1, x2, y2, x3, y3); + // 在曲线上均匀采样点用于验证 + List samplePoints = new ArrayList<>(); + int sampleCount = 10; + for (int i = 0; i < sampleCount; i++) { + double t = (double) i / (sampleCount - 1); + double sx = cubicBezier(t, x0, x1, x2, x3); + double sy = cubicBezier(t, y0, y1, y2, y3); + samplePoints.add(new Point2D.Double(sx, sy)); + } + curveData.setSamplePoints(samplePoints); + captchaExchange.setTransferData(curveData); + } + + private static double cubicBezier(double t, double p0, double p1, double p2, double p3) { + double oneMinusT = 1.0 - t; + return oneMinusT * oneMinusT * oneMinusT * p0 + + 3.0 * oneMinusT * oneMinusT * t * p1 + + 3.0 * oneMinusT * t * t * p2 + + t * t * t * p3; + } + + @Override + public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) { + GenerateParam param = captchaExchange.getParam(); + BufferedImage bg = captchaExchange.getBackgroundImage(); + Resource resImg = captchaExchange.getResourceImage(); + CustomData customData = captchaExchange.getCustomData(); + ImageTransformData transform = getImageTransform().transform(param, bg, null, resImg, null, customData); + + ImageCaptchaInfo info = ImageCaptchaInfo.of( + transform.getBackgroundImageUrl(), null, + resImg.getTag(), null, + bg.getWidth(), bg.getHeight(), 0, 0, + 0, "CURVE_DRAW"); + // 将 CurveDrawData 设置到 customData.expand 中 + CurveDrawData curveData = (CurveDrawData) captchaExchange.getTransferData(); + if (customData == null) { + customData = new CustomData(); + } + customData.setExpand(curveData); + info.setData(customData); + return info; + } + + public static class CurveDrawData { + public double x0, y0, x1, y1, x2, y2, x3, y3; + public List samplePoints; + public CurveDrawData(double x0, double y0, double x1, double y1, double x2, double y2, double x3, double y3) { + this.x0 = x0; this.y0 = y0; this.x1 = x1; this.y1 = y1; + this.x2 = x2; this.y2 = y2; this.x3 = x3; this.y3 = y3; + } + public List getSamplePoints() { return samplePoints; } + public void setSamplePoints(List samplePoints) { this.samplePoints = samplePoints; } + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CurveSliderImageCaptchaGenerator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CurveSliderImageCaptchaGenerator.java new file mode 100644 index 0000000..75f3920 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CurveSliderImageCaptchaGenerator.java @@ -0,0 +1,124 @@ +package cloud.tianai.captcha.generator.impl; + +import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator; +import cloud.tianai.captcha.generator.ImageTransform; +import cloud.tianai.captcha.generator.common.model.dto.*; +import cloud.tianai.captcha.generator.common.util.CaptchaImageUtils; +import cloud.tianai.captcha.interceptor.CaptchaInterceptor; +import cloud.tianai.captcha.resource.ImageCaptchaResourceManager; +import cloud.tianai.captcha.resource.common.model.dto.Resource; +import cloud.tianai.captcha.resource.common.model.dto.ResourceMap; + +import java.awt.*; +import java.awt.geom.CubicCurve2D; +import java.awt.geom.FlatteningPathIterator; +import java.awt.geom.PathIterator; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +public class CurveSliderImageCaptchaGenerator extends AbstractImageCaptchaGenerator { + + public static String TEMPLATE_ACTIVE_IMAGE_NAME = "active.png"; + public static String TEMPLATE_FIXED_IMAGE_NAME = "fixed.png"; + public static String TEMPLATE_MASK_IMAGE_NAME = "mask.png"; + + public CurveSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); } + public CurveSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); } + public CurveSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); } + + @Override + protected void doInit() {} + + @Override + public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) { + GenerateParam param = captchaExchange.getParam(); + ResourceMap templateResource = requiredRandomGetTemplate(param.getType(), param.getTemplateImageTag()); + Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag()); + BufferedImage background = getResourceImage(resourceImage); + BufferedImage fixedTemplate = getTemplateImage(templateResource, TEMPLATE_FIXED_IMAGE_NAME); + BufferedImage activeTemplate = getTemplateImage(templateResource, TEMPLATE_ACTIVE_IMAGE_NAME); + BufferedImage maskTemplate = getTemplateImageOfOptional(templateResource, TEMPLATE_MASK_IMAGE_NAME).orElse(fixedTemplate); + + int bgWidth = background.getWidth(); + int bgHeight = background.getHeight(); + int tplWidth = fixedTemplate.getWidth(); + + ThreadLocalRandom random = ThreadLocalRandom.current(); + double x0 = random.nextDouble(0, bgWidth * 0.1); + double y0 = random.nextDouble(bgHeight * 0.3, bgHeight * 0.7); + double x3 = random.nextDouble(bgWidth * 0.85, bgWidth * 0.95); + double y3 = random.nextDouble(bgHeight * 0.3, bgHeight * 0.7); + double x1 = random.nextDouble(bgWidth * 0.25, bgWidth * 0.45); + double y1 = random.nextDouble(bgHeight * 0.1, bgHeight * 0.9); + double x2 = random.nextDouble(bgWidth * 0.55, bgWidth * 0.75); + double y2 = random.nextDouble(bgHeight * 0.1, bgHeight * 0.9); + + CubicCurve2D curve = new CubicCurve2D.Double(x0, y0, x1, y1, x2, y2, x3, y3); + List pathPoints = sampleCurve(curve, bgWidth); + + double targetRatio = random.nextDouble(0.35, 0.75); + int targetIndex = (int) (pathPoints.size() * targetRatio); + Point targetPoint = pathPoints.get(targetIndex); + + int randomX = targetPoint.x; + int randomY = Math.max(0, Math.min(bgHeight - tplWidth, targetPoint.y - tplWidth / 2)); + + BufferedImage cutImage = CaptchaImageUtils.cutImage(background, maskTemplate, randomX, randomY); + CaptchaImageUtils.overlayImage(background, fixedTemplate, randomX, randomY); + CaptchaImageUtils.overlayImage(cutImage, activeTemplate, 0, 0); + + BufferedImage matrixTemplate = CaptchaImageUtils.createTransparentImage(activeTemplate.getWidth(), background.getHeight()); + CaptchaImageUtils.overlayImage(matrixTemplate, cutImage, 0, randomY); + + captchaExchange.setBackgroundImage(background); + captchaExchange.setTemplateImage(matrixTemplate); + captchaExchange.setTemplateResource(templateResource); + captchaExchange.setResourceImage(resourceImage); + captchaExchange.setTransferData(new CurveData(randomX, randomY, pathPoints)); + } + + @Override + public SliderImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) { + GenerateParam param = captchaExchange.getParam(); + BufferedImage bg = captchaExchange.getBackgroundImage(); + BufferedImage tpl = captchaExchange.getTemplateImage(); + Resource resImg = captchaExchange.getResourceImage(); + ResourceMap tplRes = captchaExchange.getTemplateResource(); + CustomData customData = captchaExchange.getCustomData(); + CurveData curveData = (CurveData) captchaExchange.getTransferData(); + ImageTransformData transform = getImageTransform().transform(param, bg, tpl, resImg, tplRes, customData); + + SliderImageCaptchaInfo info = SliderImageCaptchaInfo.of(curveData.x, curveData.y, + transform.getBackgroundImageUrl(), transform.getTemplateImageUrl(), + resImg.getTag(), tplRes.getTag(), + bg.getWidth(), bg.getHeight(), tpl.getWidth(), tpl.getHeight()); + info.setData(customData); + return info; + } + + private List sampleCurve(CubicCurve2D curve, int bgWidth) { + List points = new ArrayList<>(); + PathIterator pi = curve.getPathIterator(null, 0.5); + FlatteningPathIterator fpi = new FlatteningPathIterator(pi, 0.5); + double[] coords = new double[6]; + while (!fpi.isDone()) { + int type = fpi.currentSegment(coords); + if (type == PathIterator.SEG_LINETO || type == PathIterator.SEG_MOVETO) { + points.add(new Point((int) coords[0], (int) coords[1])); + } + fpi.next(); + } + return points; + } + + public static class CurveData { + public int x; + public int y; + public List pathPoints; + public CurveData(int x, int y, List pathPoints) { + this.x = x; this.y = y; this.pathPoints = pathPoints; + } + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CurveSliderV2ImageCaptchaGenerator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CurveSliderV2ImageCaptchaGenerator.java new file mode 100644 index 0000000..c7a740e --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CurveSliderV2ImageCaptchaGenerator.java @@ -0,0 +1,12 @@ +package cloud.tianai.captcha.generator.impl; + +import cloud.tianai.captcha.generator.ImageTransform; +import cloud.tianai.captcha.interceptor.CaptchaInterceptor; +import cloud.tianai.captcha.resource.ImageCaptchaResourceManager; + +public class CurveSliderV2ImageCaptchaGenerator extends CurveSliderImageCaptchaGenerator { + + public CurveSliderV2ImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); } + public CurveSliderV2ImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm, t); } + public CurveSliderV2ImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm, t, i); } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CurveSliderV3ImageCaptchaGenerator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CurveSliderV3ImageCaptchaGenerator.java new file mode 100644 index 0000000..65344d7 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CurveSliderV3ImageCaptchaGenerator.java @@ -0,0 +1,12 @@ +package cloud.tianai.captcha.generator.impl; + +import cloud.tianai.captcha.generator.ImageTransform; +import cloud.tianai.captcha.interceptor.CaptchaInterceptor; +import cloud.tianai.captcha.resource.ImageCaptchaResourceManager; + +public class CurveSliderV3ImageCaptchaGenerator extends CurveSliderImageCaptchaGenerator { + + public CurveSliderV3ImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); } + public CurveSliderV3ImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm, t); } + public CurveSliderV3ImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm, t, i); } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/EnhancedSliderImageCaptchaGenerator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/EnhancedSliderImageCaptchaGenerator.java new file mode 100644 index 0000000..fb5723c --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/EnhancedSliderImageCaptchaGenerator.java @@ -0,0 +1,118 @@ +package cloud.tianai.captcha.generator.impl; + +import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator; +import cloud.tianai.captcha.generator.ImageTransform; +import cloud.tianai.captcha.generator.common.model.dto.*; +import cloud.tianai.captcha.generator.common.util.CaptchaImageUtils; +import cloud.tianai.captcha.interceptor.CaptchaInterceptor; +import cloud.tianai.captcha.obfuscator.CompositeObfuscator; +import cloud.tianai.captcha.obfuscator.ImageObfuscator; +import cloud.tianai.captcha.obfuscator.ObfuscatorConfig; +import cloud.tianai.captcha.resource.ImageCaptchaResourceManager; +import cloud.tianai.captcha.resource.common.model.dto.Resource; +import cloud.tianai.captcha.resource.common.model.dto.ResourceMap; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.util.Optional; + +public class EnhancedSliderImageCaptchaGenerator extends AbstractImageCaptchaGenerator { + + public static String TEMPLATE_ACTIVE_IMAGE_NAME = "active.png"; + public static String TEMPLATE_FIXED_IMAGE_NAME = "fixed.png"; + public static String TEMPLATE_MASK_IMAGE_NAME = "mask.png"; + public static String OBFUSCATE_TEMPLATE_FIXED_IMAGE_NAME = "obfuscate_" + TEMPLATE_FIXED_IMAGE_NAME; + + private ImageObfuscator obfuscator = new CompositeObfuscator(); + private ObfuscatorConfig obfuscatorConfig = ObfuscatorConfig.defaultConfig(); + + public EnhancedSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm) { + super(rm); + } + + public EnhancedSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform) { + super(rm); + setImageTransform(transform); + } + + public EnhancedSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform, CaptchaInterceptor interceptor) { + super(rm); + setImageTransform(transform); + setInterceptor(interceptor); + } + + @Override + protected void doInit() {} + + @Override + public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) { + GenerateParam param = captchaExchange.getParam(); + Boolean obfuscate = param.getObfuscate(); + ResourceMap templateResource = requiredRandomGetTemplate(param.getType(), param.getTemplateImageTag()); + Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag()); + BufferedImage background = getResourceImage(resourceImage); + BufferedImage fixedTemplate = getTemplateImage(templateResource, TEMPLATE_FIXED_IMAGE_NAME); + BufferedImage activeTemplate = getTemplateImage(templateResource, TEMPLATE_ACTIVE_IMAGE_NAME); + BufferedImage maskTemplate = fixedTemplate; + Optional maskOpt = getTemplateImageOfOptional(templateResource, TEMPLATE_MASK_IMAGE_NAME); + if (maskOpt.isPresent()) { + maskTemplate = maskOpt.get(); + } + + int randomX = randomInt(fixedTemplate.getWidth() + 5, background.getWidth() - fixedTemplate.getWidth() - 10); + int randomY = randomInt(background.getHeight() - fixedTemplate.getHeight()); + + BufferedImage cutImage = CaptchaImageUtils.cutImage(background, maskTemplate, randomX, randomY); + CaptchaImageUtils.overlayImage(background, fixedTemplate, randomX, randomY); + + if (obfuscate) { + Optional obfOpt = getTemplateImageOfOptional(templateResource, OBFUSCATE_TEMPLATE_FIXED_IMAGE_NAME); + BufferedImage obfImage = obfOpt.orElseGet(() -> new StandardSliderImageCaptchaGenerator(getImageResourceManager()) {}.createObfuscate(fixedTemplate)); + int obfX = randomObfuscateX(randomX, fixedTemplate.getWidth(), background.getWidth()); + CaptchaImageUtils.overlayImage(background, obfImage, obfX, randomY); + } + + CaptchaImageUtils.overlayImage(cutImage, activeTemplate, 0, 0); + BufferedImage matrixTemplate = CaptchaImageUtils.createTransparentImage(activeTemplate.getWidth(), background.getHeight()); + CaptchaImageUtils.overlayImage(matrixTemplate, cutImage, 0, randomY); + + if (obfuscate) { + background = obfuscator.obfuscate(background, obfuscatorConfig); + } + + captchaExchange.setBackgroundImage(background); + captchaExchange.setTemplateImage(matrixTemplate); + captchaExchange.setTemplateResource(templateResource); + captchaExchange.setResourceImage(resourceImage); + captchaExchange.setTransferData(new Point(randomX, randomY)); + } + + @Override + public SliderImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) { + GenerateParam param = captchaExchange.getParam(); + BufferedImage backgroundImage = captchaExchange.getBackgroundImage(); + BufferedImage sliderImage = captchaExchange.getTemplateImage(); + Resource resourceImage = captchaExchange.getResourceImage(); + ResourceMap templateResource = captchaExchange.getTemplateResource(); + CustomData customData = captchaExchange.getCustomData(); + Point data = (Point) captchaExchange.getTransferData(); + ImageTransformData transform = getImageTransform().transform(param, backgroundImage, sliderImage, resourceImage, templateResource, customData); + SliderImageCaptchaInfo info = SliderImageCaptchaInfo.of(data.x, data.y, + transform.getBackgroundImageUrl(), transform.getTemplateImageUrl(), + resourceImage.getTag(), templateResource.getTag(), + backgroundImage.getWidth(), backgroundImage.getHeight(), + sliderImage.getWidth(), sliderImage.getHeight()); + info.setData(customData); + return info; + } + + protected int randomObfuscateX(int sliderX, int slWidth, int bgWidth) { + if (bgWidth / 2 > (sliderX + (slWidth / 2))) { + return randomInt(sliderX + slWidth, bgWidth - slWidth); + } + return randomInt(slWidth, sliderX - slWidth); + } + + public void setObfuscator(ImageObfuscator obfuscator) { this.obfuscator = obfuscator; } + public void setObfuscatorConfig(ObfuscatorConfig config) { this.obfuscatorConfig = config; } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/IconClickImageCaptchaGenerator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/IconClickImageCaptchaGenerator.java new file mode 100644 index 0000000..3b51364 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/IconClickImageCaptchaGenerator.java @@ -0,0 +1,175 @@ +package cloud.tianai.captcha.generator.impl; + +import cloud.tianai.captcha.common.constant.CaptchaTypeConstant; +import cloud.tianai.captcha.common.constant.CommonConstant; +import cloud.tianai.captcha.generator.ImageTransform; +import cloud.tianai.captcha.generator.common.model.dto.*; +import cloud.tianai.captcha.interceptor.CaptchaInterceptor; +import cloud.tianai.captcha.resource.ImageCaptchaResourceManager; +import cloud.tianai.captcha.resource.common.model.dto.Resource; +import cloud.tianai.captcha.resource.common.model.dto.ResourceMap; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 图标点选验证码。 + *

+ * 图标使用预渲染的 PNG 资源(classpath: META-INF/captcha-icons/*.png), + * 不依赖运行环境字体(Linux 容器通常没有 emoji 字体,直接字体渲染会得到豆腐块)。 + * 图标资产由 tools/icon-render/RenderIcons.java 在开发机(有 Segoe UI Emoji 字体)预先生成, + * 新增/替换图标时在开发机重跑该工具即可。 + */ +public class IconClickImageCaptchaGenerator extends AbstractClickImageCaptchaGenerator { + + /** 图标分类,每次随机取一类并从中挑 3 个。名称对应 META-INF/captcha-icons/<name>.png */ + private static final List ICON_CATEGORIES = List.of( + new String[]{"apple", "banana", "cherry", "grapes", "lemon", "orange", "strawberry", "watermelon"}, + new String[]{"dog", "cat", "mouse", "hamster", "rabbit", "fox", "bear", "panda"}, + new String[]{"soccer", "basketball", "tennis", "volleyball", "billiards", "pingpong", "trophy", "boxing"}, + new String[]{"car", "taxi", "suv", "bus", "racecar", "police", "ambulance", "firetruck"}, + new String[]{"phone", "laptop", "desktop", "printer", "keyboard", "computer-mouse", "cd", "camera"} + ); + + private static final String ICON_BASE_PATH = "META-INF/captcha-icons/"; + /** 图标在背景图上的渲染尺寸 */ + private static final int ICON_RENDER_SIZE = 48; + /** 提示条内单个图标尺寸 */ + private static final int TIP_ICON_SIZE = 36; + /** 提示条内边距 */ + private static final int TIP_PADDING = 8; + + /** classpath PNG 加载缓存(icon name -> image),PNG 本身不可变,可安全共享 */ + private static final Map ICON_CACHE = new ConcurrentHashMap<>(48); + + public IconClickImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); } + public IconClickImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); } + public IconClickImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); } + + @Override + protected void doInit() {} + + @Override + protected List randomGetClickImgTips(GenerateParam param) { + ThreadLocalRandom random = ThreadLocalRandom.current(); + String[] category = ICON_CATEGORIES.get(random.nextInt(ICON_CATEGORIES.size())); + List icons = new ArrayList<>(Arrays.asList(category)); + Collections.shuffle(icons, random); + int count = Math.min(3, icons.size()); + List result = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + ResourceMap map = new ResourceMap("default", 2); + Resource iconResource = new Resource("icon", icons.get(i), "default"); + map.put(CommonConstant.IMAGE_CLICK_ICON, iconResource); + map.put(CommonConstant.IMAGE_TIP_ICON, iconResource); + result.add(map); + } + return result; + } + + @Override + public ClickImageCheckDefinition.ImgWrapper getClickImg(GenerateParam param, Resource tip, Color randomColor, BufferedImage bgImage) { + BufferedImage source = loadIcon(tip.getData()); + BufferedImage scaled = new BufferedImage(ICON_RENDER_SIZE, ICON_RENDER_SIZE, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = scaled.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.drawImage(source, 0, 0, ICON_RENDER_SIZE, ICON_RENDER_SIZE, null); + } finally { + g.dispose(); + } + ClickImageCheckDefinition.ImgWrapper wrapper = new ClickImageCheckDefinition.ImgWrapper(); + wrapper.setImage(scaled); + wrapper.setImageColor(Color.BLACK); + return wrapper; + } + + @Override + protected List filterAndSortClickImageCheckDefinition(CaptchaExchange captchaExchange, List allCheckDefinitionList) { + // 3 个图标全部参与校验,保持生成顺序(与提示条顺序一致) + return allCheckDefinitionList; + } + + @Override + public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) { + List checkList = (List) captchaExchange.getTransferData(); + BufferedImage bgImage = captchaExchange.getBackgroundImage(); + Resource resourceImage = captchaExchange.getResourceImage(); + CustomData customData = captchaExchange.getCustomData(); + // 提示条:按点击顺序横向拼接图标,作为 templateImage 返回给前端展示 + BufferedImage tipImage = genTipImage(checkList); + ImageTransformData transform = getImageTransform().transform(captchaExchange.getParam(), bgImage, tipImage, resourceImage, checkList, customData); + + ImageCaptchaInfo info = new ImageCaptchaInfo(); + info.setBackgroundImage(transform.getBackgroundImageUrl()); + info.setTemplateImage(transform.getTemplateImageUrl()); + info.setBackgroundImageTag(resourceImage.getTag()); + info.setBackgroundImageWidth(bgImage.getWidth()); + info.setBackgroundImageHeight(bgImage.getHeight()); + info.setTemplateImageWidth(tipImage.getWidth()); + info.setTemplateImageHeight(tipImage.getHeight()); + info.setType(CaptchaTypeConstant.ICON_CLICK); + customData.setExpand(checkList); + info.setData(customData); + return info; + } + + /** + * 将参与校验的图标按点击顺序横向拼接为提示条图(白底圆角,保证任意背景上可读)。 + */ + private BufferedImage genTipImage(List checkList) { + int n = checkList.size(); + int width = TIP_PADDING + n * (TIP_ICON_SIZE + TIP_PADDING); + int height = TIP_ICON_SIZE + 2 * TIP_PADDING; + BufferedImage tip = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = tip.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setColor(new Color(255, 255, 255, 235)); + g.fillRoundRect(0, 0, width - 1, height - 1, 12, 12); + g.setColor(new Color(0, 0, 0, 60)); + g.drawRoundRect(0, 0, width - 1, height - 1, 12, 12); + int x = TIP_PADDING; + for (ClickImageCheckDefinition def : checkList) { + BufferedImage icon = def.getTipImage() != null && def.getTipImage().getImage() != null + ? def.getTipImage().getImage() + : loadIcon(def.getTip().getData()); + g.drawImage(icon, x, TIP_PADDING, TIP_ICON_SIZE, TIP_ICON_SIZE, null); + x += TIP_ICON_SIZE + TIP_PADDING; + } + } finally { + g.dispose(); + } + return tip; + } + + private static BufferedImage loadIcon(String name) { + return ICON_CACHE.computeIfAbsent(name, n -> { + String path = ICON_BASE_PATH + n + ".png"; + try (InputStream in = IconClickImageCaptchaGenerator.class.getClassLoader().getResourceAsStream(path)) { + if (in == null) { + throw new IllegalStateException("图标资源不存在: " + path); + } + BufferedImage img = ImageIO.read(in); + if (img == null) { + throw new IllegalStateException("图标资源解析失败: " + path); + } + return img; + } catch (IOException e) { + throw new IllegalStateException("图标资源读取失败: " + path, e); + } + }); + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/JigsawImageCaptchaGenerator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/JigsawImageCaptchaGenerator.java new file mode 100644 index 0000000..83242b2 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/JigsawImageCaptchaGenerator.java @@ -0,0 +1,123 @@ +package cloud.tianai.captcha.generator.impl; + +import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator; +import cloud.tianai.captcha.generator.ImageTransform; +import cloud.tianai.captcha.generator.common.model.dto.*; +import cloud.tianai.captcha.generator.common.util.CaptchaImageUtils; +import cloud.tianai.captcha.interceptor.CaptchaInterceptor; +import cloud.tianai.captcha.resource.ImageCaptchaResourceManager; +import cloud.tianai.captcha.resource.common.model.dto.Resource; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +public class JigsawImageCaptchaGenerator extends AbstractImageCaptchaGenerator { + + private int gridCols = 3; + private int gridRows = 2; + + public JigsawImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); } + public JigsawImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); } + public JigsawImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); } + + @Override + protected void doInit() {} + + @Override + public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) { + GenerateParam param = captchaExchange.getParam(); + Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag()); + BufferedImage background = getResourceImage(resourceImage); + + int width = background.getWidth(); + int height = background.getHeight(); + int tileWidth = width / gridCols; + int tileHeight = height / gridRows; + + int totalTiles = gridCols * gridRows; + List positions = new ArrayList<>(totalTiles); + for (int i = 0; i < totalTiles; i++) positions.add(i); + List shuffled = new ArrayList<>(positions); + Collections.shuffle(shuffled, ThreadLocalRandom.current()); + + BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = result.createGraphics(); + try { + for (int i = 0; i < totalTiles; i++) { + int srcRow = i / gridCols; + int srcCol = i % gridCols; + int dstRow = shuffled.get(i) / gridCols; + int dstCol = shuffled.get(i) % gridCols; + int srcX = srcCol * tileWidth; + int srcY = srcRow * tileHeight; + int dstX = dstCol * tileWidth; + int dstY = dstRow * tileHeight; + BufferedImage tile = background.getSubimage( + Math.min(srcX, width - tileWidth), + Math.min(srcY, height - tileHeight), + tileWidth, tileHeight); + g2d.drawImage(tile, dstX, dstY, null); + } + g2d.setColor(new Color(255, 255, 255, 80)); + for (int c = 1; c < gridCols; c++) { + g2d.drawLine(c * tileWidth, 0, c * tileWidth, height); + } + for (int r = 1; r < gridRows; r++) { + g2d.drawLine(0, r * tileHeight, width, r * tileHeight); + } + } finally { + g2d.dispose(); + } + + List restoreOrder = new ArrayList<>(totalTiles); + Integer[] restore = new Integer[totalTiles]; + for (int i = 0; i < totalTiles; i++) { + restore[shuffled.get(i)] = i; + } + Collections.addAll(restoreOrder, restore); + + captchaExchange.setBackgroundImage(result); + captchaExchange.setTemplateImage(null); + captchaExchange.setResourceImage(resourceImage); + captchaExchange.setTransferData(new JigsawData(restoreOrder, gridCols, gridRows)); + } + + @Override + public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) { + GenerateParam param = captchaExchange.getParam(); + BufferedImage bg = captchaExchange.getBackgroundImage(); + Resource resImg = captchaExchange.getResourceImage(); + CustomData customData = captchaExchange.getCustomData(); + JigsawData data = (JigsawData) captchaExchange.getTransferData(); + ImageTransformData transform = getImageTransform().transform(param, bg, null, resImg, null, customData); + + ImageCaptchaInfo info = ImageCaptchaInfo.of( + transform.getBackgroundImageUrl(), null, + resImg.getTag(), null, + bg.getWidth(), bg.getHeight(), 0, 0, + 0, "JIGSAW"); + info.setData(customData); + // Store restore order as expand for validator + customData.expand = data.restoreOrder.stream().map(String::valueOf).reduce((a, b) -> a + "," + b).orElse(""); + // Also pass cols/rows to frontend via viewData + customData.putViewData("cols", data.cols); + customData.putViewData("rows", data.rows); + return info; + } + + public void setGridCols(int cols) { this.gridCols = cols; } + public void setGridRows(int rows) { this.gridRows = rows; } + + public static class JigsawData { + public List restoreOrder; + public int cols; + public int rows; + public JigsawData(List restoreOrder, int cols, int rows) { + this.restoreOrder = restoreOrder; this.cols = cols; this.rows = rows; + } + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/ProofOfWorkCaptchaGenerator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/ProofOfWorkCaptchaGenerator.java new file mode 100644 index 0000000..adc28c6 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/ProofOfWorkCaptchaGenerator.java @@ -0,0 +1,74 @@ +package cloud.tianai.captcha.generator.impl; + +import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator; +import cloud.tianai.captcha.generator.ImageTransform; +import cloud.tianai.captcha.generator.common.model.dto.*; +import cloud.tianai.captcha.interceptor.CaptchaInterceptor; +import cloud.tianai.captcha.resource.ImageCaptchaResourceManager; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.concurrent.ThreadLocalRandom; + +public class ProofOfWorkCaptchaGenerator extends AbstractImageCaptchaGenerator { + + private int difficulty = 4; + + public ProofOfWorkCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); } + public ProofOfWorkCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); } + public ProofOfWorkCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); } + + @Override + protected void doInit() {} + + @Override + public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) { + GenerateParam param = captchaExchange.getParam(); + byte[] challenge = new byte[32]; + new SecureRandom().nextBytes(challenge); + String challengeStr = Base64.getUrlEncoder().withoutPadding().encodeToString(challenge); + captchaExchange.setTransferData(new PoWData(challengeStr, difficulty)); + } + + @Override + public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) { + PoWData powData = (PoWData) captchaExchange.getTransferData(); + ImageCaptchaInfo info = new ImageCaptchaInfo(); + info.setType("PROOF_OF_WORK"); + info.setRandomX(powData.difficulty); + // Put data on the exchange's customData, not a new one + // (AbstractImageCaptchaGenerator.generateCaptchaImage overwrites info.data with exchange.customData) + CustomData customData = captchaExchange.getCustomData(); + customData.putViewData("challenge", powData.challenge); + customData.putViewData("difficulty", powData.difficulty); + customData.expand = powData; + info.setData(customData); + return info; + } + + public static boolean verifyProof(String challenge, int difficulty, String nonce) { + try { + String input = challenge + nonce; + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + for (int i = 0; i < difficulty; i++) { + if (hash[i] != 0) return false; + } + return true; + } catch (Exception e) { + return false; + } + } + + public void setDifficulty(int d) { this.difficulty = d; } + + public static class PoWData { + public String challenge; + public int difficulty; + public PoWData(String challenge, int difficulty) { + this.challenge = challenge; this.difficulty = difficulty; + } + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/ScratchImageCaptchaGenerator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/ScratchImageCaptchaGenerator.java new file mode 100644 index 0000000..93ec141 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/ScratchImageCaptchaGenerator.java @@ -0,0 +1,113 @@ +package cloud.tianai.captcha.generator.impl; + +import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator; +import cloud.tianai.captcha.generator.ImageTransform; +import cloud.tianai.captcha.generator.common.model.dto.*; +import cloud.tianai.captcha.interceptor.CaptchaInterceptor; +import cloud.tianai.captcha.resource.ImageCaptchaResourceManager; +import cloud.tianai.captcha.resource.common.model.dto.Resource; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.util.concurrent.ThreadLocalRandom; + +public class ScratchImageCaptchaGenerator extends AbstractImageCaptchaGenerator { + + private float coverOpacity = 0.75f; + private Color coverColor = new Color(180, 180, 180); + + public ScratchImageCaptchaGenerator(ImageCaptchaResourceManager rm) { + super(rm); + } + + public ScratchImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform) { + super(rm); + setImageTransform(transform); + } + + public ScratchImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform, CaptchaInterceptor interceptor) { + super(rm); + setImageTransform(transform); + setInterceptor(interceptor); + } + + @Override + protected void doInit() {} + + @Override + public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) { + GenerateParam param = captchaExchange.getParam(); + Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag()); + BufferedImage background = getResourceImage(resourceImage); + int width = background.getWidth(); + int height = background.getHeight(); + + BufferedImage cover = createCoverLayer(width, height); + addScratchPattern(cover, width, height); + + captchaExchange.setBackgroundImage(background); + captchaExchange.setTemplateImage(cover); + captchaExchange.setResourceImage(resourceImage); + captchaExchange.setTransferData(new ScratchData(width, height)); + } + + @Override + public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) { + GenerateParam param = captchaExchange.getParam(); + BufferedImage backgroundImage = captchaExchange.getBackgroundImage(); + BufferedImage coverImage = captchaExchange.getTemplateImage(); + Resource resourceImage = captchaExchange.getResourceImage(); + CustomData customData = captchaExchange.getCustomData(); + ImageTransformData transform = getImageTransform().transform(param, backgroundImage, coverImage, resourceImage, null, customData); + + ScratchData scratchData = (ScratchData) captchaExchange.getTransferData(); + ImageCaptchaInfo info = ImageCaptchaInfo.of( + transform.getBackgroundImageUrl(), transform.getTemplateImageUrl(), + resourceImage.getTag(), null, + backgroundImage.getWidth(), backgroundImage.getHeight(), + coverImage.getWidth(), coverImage.getHeight(), + scratchData.width / 2, "SCRATCH"); + info.setData(customData); + // Store scratch threshold in expand for validator + customData.expand = 50; // 50% threshold + return info; + } + + private BufferedImage createCoverLayer(int width, int height) { + BufferedImage cover = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = cover.createGraphics(); + try { + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, coverOpacity)); + g2d.setColor(coverColor); + g2d.fillRect(0, 0, width, height); + } finally { + g2d.dispose(); + } + return cover; + } + + private void addScratchPattern(BufferedImage cover, int width, int height) { + Graphics2D g2d = cover.createGraphics(); + try { + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.3f)); + g2d.setColor(Color.LIGHT_GRAY); + ThreadLocalRandom random = ThreadLocalRandom.current(); + for (int i = 0; i < 8; i++) { + int x1 = random.nextInt(width); + int y1 = random.nextInt(height); + int x2 = random.nextInt(width); + int y2 = random.nextInt(height); + g2d.setStroke(new BasicStroke(2 + random.nextFloat() * 3)); + g2d.drawLine(x1, y1, x2, y2); + } + } finally { + g2d.dispose(); + } + } + + public static class ScratchData { + public int width; + public int height; + public ScratchData(int w, int h) { this.width = w; this.height = h; } + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/WordOrderClickImageCaptchaGenerator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/WordOrderClickImageCaptchaGenerator.java new file mode 100644 index 0000000..d090620 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/WordOrderClickImageCaptchaGenerator.java @@ -0,0 +1,102 @@ +package cloud.tianai.captcha.generator.impl; + +import cloud.tianai.captcha.common.constant.CommonConstant; +import cloud.tianai.captcha.generator.ImageTransform; +import cloud.tianai.captcha.generator.common.model.dto.*; +import cloud.tianai.captcha.interceptor.CaptchaInterceptor; +import cloud.tianai.captcha.resource.ImageCaptchaResourceManager; +import cloud.tianai.captcha.resource.common.model.dto.Resource; +import cloud.tianai.captcha.resource.common.model.dto.ResourceMap; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +public class WordOrderClickImageCaptchaGenerator extends AbstractClickImageCaptchaGenerator { + + private static final String[] PHRASES = { + "春暖花开", "风和日丽", "山清水秀", "鸟语花香", + "天高云淡", "秋高气爽", "冰天雪地", "春华秋实", + "龙飞凤舞", "万紫千红", "花好月圆", "国泰民安" + }; + + public WordOrderClickImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); } + public WordOrderClickImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); } + public WordOrderClickImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); } + + @Override + protected void doInit() {} + + @Override + protected List randomGetClickImgTips(GenerateParam param) { + ThreadLocalRandom random = ThreadLocalRandom.current(); + String phrase = PHRASES[random.nextInt(PHRASES.length)]; + char[] chars = phrase.toCharArray(); + List result = new ArrayList<>(chars.length); + for (char c : chars) { + ResourceMap map = new ResourceMap("default", 2); + Resource charResource = new Resource("char", String.valueOf(c), "default"); + map.put(CommonConstant.IMAGE_CLICK_ICON, charResource); + map.put(CommonConstant.IMAGE_TIP_ICON, charResource); + result.add(map); + } + return result; + } + + @Override + public ClickImageCheckDefinition.ImgWrapper getClickImg(GenerateParam param, Resource tip, Color randomColor, BufferedImage bgImage) { + String text = tip.getData(); + int fontSize = 28; + ThreadLocalRandom random = ThreadLocalRandom.current(); + float rotation = (random.nextFloat() - 0.5f) * 0.4f; + float scale = 0.9f + random.nextFloat() * 0.3f; + + BufferedImage img = new BufferedImage(fontSize * 2, fontSize * 2, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = img.createGraphics(); + try { + g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g2d.translate(fontSize, fontSize); + g2d.rotate(rotation); + g2d.scale(scale, scale); + g2d.setFont(new Font("SimHei", Font.BOLD, fontSize)); + g2d.setColor(randomColor != null ? randomColor : Color.BLACK); + FontMetrics fm = g2d.getFontMetrics(); + g2d.drawString(text, -fm.stringWidth(text) / 2, fm.getAscent() / 2); + } finally { + g2d.dispose(); + } + ClickImageCheckDefinition.ImgWrapper wrapper = new ClickImageCheckDefinition.ImgWrapper(); + wrapper.setImage(img); + wrapper.setImageColor(randomColor != null ? randomColor : Color.BLACK); + return wrapper; + } + + @Override + protected List filterAndSortClickImageCheckDefinition(CaptchaExchange captchaExchange, List allCheckDefinitionList) { + return allCheckDefinitionList; + } + + @Override + public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) { + GenerateParam param = captchaExchange.getParam(); + BufferedImage bgImage = captchaExchange.getBackgroundImage(); + Resource resourceImage = captchaExchange.getResourceImage(); + CustomData customData = captchaExchange.getCustomData(); + List checkList = (List) captchaExchange.getTransferData(); + ImageTransformData transform = getImageTransform().transform(param, bgImage, null, resourceImage, null, customData); + + ImageCaptchaInfo info = ImageCaptchaInfo.of( + transform.getBackgroundImageUrl(), null, + resourceImage.getTag(), null, + bgImage.getWidth(), bgImage.getHeight(), 0, 0, + 0, "WORD_ORDER_CLICK"); + info.setData(customData); + if (checkList != null && customData != null) { + customData.expand = checkList; + } + return info; + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/interceptor/impl/RiskControlInterceptor.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/interceptor/impl/RiskControlInterceptor.java new file mode 100644 index 0000000..5d9384a --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/interceptor/impl/RiskControlInterceptor.java @@ -0,0 +1,49 @@ +package cloud.tianai.captcha.interceptor.impl; + +import cloud.tianai.captcha.common.AnyMap; +import cloud.tianai.captcha.common.response.ApiResponse; +import cloud.tianai.captcha.interceptor.CaptchaInterceptor; +import cloud.tianai.captcha.interceptor.Context; +import cloud.tianai.captcha.risk.RiskEngine; +import cloud.tianai.captcha.validator.common.model.dto.MatchParam; + +public class RiskControlInterceptor implements CaptchaInterceptor { + + private final RiskEngine riskEngine; + + public RiskControlInterceptor(RiskEngine riskEngine) { + this.riskEngine = riskEngine; + } + + @Override + public ApiResponse beforeValid(Context context, String type, MatchParam matchParam, AnyMap validData) { + String ip = extractIp(matchParam); + String id = context != null ? context.getName() : "unknown"; + RiskEngine.RiskResult result = riskEngine.check(ip, id); + if (!result.isAllowed()) { + return ApiResponse.of(4003, result.getReason(), null); + } + return ApiResponse.ofSuccess(); + } + + @Override + public ApiResponse afterValid(Context context, String type, MatchParam matchParam, AnyMap validData, ApiResponse basicValid) { + String ip = extractIp(matchParam); + if (basicValid != null && basicValid.isSuccess()) { + riskEngine.recordSuccess(ip); + } else { + riskEngine.recordFail(ip); + } + return ApiResponse.ofSuccess(); + } + + private String extractIp(MatchParam matchParam) { + if (matchParam != null) { + Object ip = matchParam.get("ip"); + if (ip instanceof String) { + return (String) ip; + } + } + return "unknown"; + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/ml/TrackFeatureExtractor.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/ml/TrackFeatureExtractor.java new file mode 100644 index 0000000..c31ad81 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/ml/TrackFeatureExtractor.java @@ -0,0 +1,245 @@ +package cloud.tianai.captcha.ml; + +import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack; +import java.util.ArrayList; +import java.util.List; + +public class TrackFeatureExtractor { + + public TrackFeatures extract(ImageCaptchaTrack track) { + List trackList = track.getTrackList(); + if (trackList == null || trackList.isEmpty()) { + return TrackFeatures.empty(); + } + + TrackFeatures features = new TrackFeatures(); + features.totalPoints = trackList.size(); + + List xList = new ArrayList<>(trackList.size()); + List yList = new ArrayList<>(trackList.size()); + List tList = new ArrayList<>(trackList.size()); + + for (ImageCaptchaTrack.Track p : trackList) { + xList.add(p.getX()); + yList.add(p.getY()); + tList.add(p.getT()); + } + + features.totalDuration = track.getStopTime() != null && track.getStartTime() != null + ? track.getStopTime() - track.getStartTime() + : (tList.size() >= 2 ? (long)(tList.get(tList.size() - 1) - tList.get(0)) : 0L); + + float startX = xList.get(0); + float startY = yList.get(0); + float endX = xList.get(xList.size() - 1); + float endY = yList.get(yList.size() - 1); + + features.startX = startX; + features.startY = startY; + features.endX = endX; + features.endY = endY; + + int bgWidth = track.getBgImageWidth() != null ? track.getBgImageWidth() : 600; + features.displacementX = endX - startX; + features.displacementY = endY - startY; + features.displacementXRatio = features.displacementX / bgWidth; + + List speeds = new ArrayList<>(trackList.size() - 1); + List accelerations = new ArrayList<>(Math.max(0, trackList.size() - 2)); + double totalPathLength = 0; + + for (int i = 1; i < trackList.size(); i++) { + float dx = xList.get(i) - xList.get(i - 1); + float dy = yList.get(i) - yList.get(i - 1); + float dt = tList.get(i) - tList.get(i - 1); + double dist = Math.sqrt(dx * dx + dy * dy); + totalPathLength += dist; + + if (dt > 0) { + speeds.add((float)(dist / dt)); + } + } + + features.totalPathLength = totalPathLength; + features.pathEfficiency = totalPathLength > 0 + ? Math.sqrt(features.displacementX * features.displacementX + features.displacementY * features.displacementY) / totalPathLength + : 0; + + for (int i = 1; i < speeds.size(); i++) { + accelerations.add(speeds.get(i) - speeds.get(i - 1)); + } + + features.avgSpeed = average(speeds); + features.maxSpeed = max(speeds); + features.minSpeed = min(speeds); + features.speedVariance = variance(speeds); + features.speedStdDev = stdDev(speeds); + features.speedSkewness = skewness(speeds); + + features.avgAcceleration = average(accelerations); + features.maxAcceleration = max(accelerations); + features.minAcceleration = min(accelerations); + features.accelerationVariance = variance(accelerations); + + features.directionChanges = countDirectionChanges(xList); + features.yDirectionChanges = countDirectionChanges(yList); + + features.pauses = countPauses(tList, 50); + + features.startOffset = Math.sqrt(startX * startX + startY * startY); + + features.straightness = calculateStraightness(xList, yList); + + features.xUniformity = calculateUniformity(xList); + features.yUniformity = calculateUniformity(yList); + + features.avgPointInterval = tList.size() > 1 + ? (tList.get(tList.size() - 1) - tList.get(0)) / (float)(tList.size() - 1) + : 0; + + features.speedPhaseCorrelation = calculateSpeedPhaseCorrelation(speeds); + + features.maxJumpDistance = calculateMaxJump(xList, yList); + + features.overshootRatio = calculateOvershoot(xList, startX, endX); + + return features; + } + + private float average(List values) { + if (values.isEmpty()) return 0; + float sum = 0; + for (float v : values) sum += v; + return sum / values.size(); + } + + private float max(List values) { + if (values.isEmpty()) return 0; + float m = Float.MIN_VALUE; + for (float v : values) if (v > m) m = v; + return m; + } + + private float min(List values) { + if (values.isEmpty()) return 0; + float m = Float.MAX_VALUE; + for (float v : values) if (v < m) m = v; + return m; + } + + private double variance(List values) { + if (values.size() < 2) return 0; + float avg = average(values); + double sum = 0; + for (float v : values) sum += (v - avg) * (v - avg); + return sum / (values.size() - 1); + } + + private double stdDev(List values) { + return Math.sqrt(variance(values)); + } + + private double skewness(List values) { + if (values.size() < 3) return 0; + float avg = average(values); + double sd = stdDev(values); + if (sd == 0) return 0; + double sum = 0; + for (float v : values) { + double norm = (v - avg) / sd; + sum += norm * norm * norm; + } + return sum / values.size(); + } + + private int countDirectionChanges(List values) { + int changes = 0; + for (int i = 2; i < values.size(); i++) { + float d1 = values.get(i - 1) - values.get(i - 2); + float d2 = values.get(i) - values.get(i - 1); + if (d1 * d2 < 0) changes++; + } + return changes; + } + + private int countPauses(List tList, long thresholdMs) { + int pauses = 0; + for (int i = 1; i < tList.size(); i++) { + if (tList.get(i) - tList.get(i - 1) > thresholdMs) pauses++; + } + return pauses; + } + + private double calculateStraightness(List xList, List yList) { + if (xList.size() < 3) return 1.0; + float startX = xList.get(0); + float startY = yList.get(0); + float endX = xList.get(xList.size() - 1); + float endY = yList.get(yList.size() - 1); + double lineLength = Math.sqrt((endX - startX) * (endX - startX) + (endY - startY) * (endY - startY)); + if (lineLength == 0) return 1.0; + double totalDeviation = 0; + for (int i = 1; i < xList.size() - 1; i++) { + double d = pointToLineDistance(xList.get(i), yList.get(i), startX, startY, endX, endY); + totalDeviation += d; + } + return totalDeviation / (xList.size() - 2) / lineLength; + } + + private double pointToLineDistance(float px, float py, float x1, float y1, float x2, float y2) { + double A = py - y1; + double B = x1 - x2; + double C = x2 * y1 - x1 * y2; + double denom = Math.sqrt(A * A + B * B); + if (denom == 0) return 0; + return Math.abs(A * px + B * py + C) / denom; + } + + private double calculateUniformity(List values) { + if (values.size() < 3) return 0; + List diffs = new ArrayList<>(values.size() - 1); + for (int i = 1; i < values.size(); i++) { + diffs.add(values.get(i) - values.get(i - 1)); + } + return stdDev(diffs); + } + + private double calculateSpeedPhaseCorrelation(List speeds) { + if (speeds.size() < 4) return 0; + int mid = speeds.size() / 2; + float avgFirst = average(speeds.subList(0, mid)); + float avgSecond = average(speeds.subList(mid, speeds.size())); + float overallAvg = average(speeds); + if (overallAvg == 0) return 0; + return (avgFirst - avgSecond) / overallAvg; + } + + private double calculateMaxJump(List xList, List yList) { + double maxJump = 0; + for (int i = 1; i < xList.size(); i++) { + double dx = xList.get(i) - xList.get(i - 1); + double dy = yList.get(i) - yList.get(i - 1); + double jump = Math.sqrt(dx * dx + dy * dy); + if (jump > maxJump) maxJump = jump; + } + return maxJump; + } + + private double calculateOvershoot(List xList, float startX, float endX) { + if (xList.isEmpty()) return 0; + float target = endX; + float maxOvershoot = 0; + boolean passedTarget = false; + for (Float x : xList) { + if (!passedTarget && Math.abs(x - target) < 5) { + passedTarget = true; + } + if (passedTarget) { + float overshoot = Math.abs(x - target); + if (overshoot > maxOvershoot) maxOvershoot = overshoot; + } + } + float totalDisplacement = Math.abs(endX - startX); + return totalDisplacement > 0 ? maxOvershoot / totalDisplacement : 0; + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/ml/TrackFeatures.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/ml/TrackFeatures.java new file mode 100644 index 0000000..f081194 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/ml/TrackFeatures.java @@ -0,0 +1,74 @@ +package cloud.tianai.captcha.ml; + +public class TrackFeatures { + + public int totalPoints; + public long totalDuration; + public float startX; + public float startY; + public float endX; + public float endY; + public float displacementX; + public float displacementY; + public float displacementXRatio; + public double totalPathLength; + public double pathEfficiency; + public float avgSpeed; + public float maxSpeed; + public float minSpeed; + public double speedVariance; + public double speedStdDev; + public double speedSkewness; + public float avgAcceleration; + public float maxAcceleration; + public float minAcceleration; + public double accelerationVariance; + public int directionChanges; + public int yDirectionChanges; + public int pauses; + public double startOffset; + public double straightness; + public double xUniformity; + public double yUniformity; + public float avgPointInterval; + public double speedPhaseCorrelation; + public double maxJumpDistance; + public double overshootRatio; + + public static TrackFeatures empty() { + return new TrackFeatures(); + } + + public double[] toArray() { + return new double[]{ + totalPoints, + totalDuration, + displacementX, + displacementY, + displacementXRatio, + totalPathLength, + pathEfficiency, + avgSpeed, + maxSpeed, + minSpeed, + speedVariance, + speedStdDev, + speedSkewness, + avgAcceleration, + maxAcceleration, + minAcceleration, + accelerationVariance, + directionChanges, + yDirectionChanges, + pauses, + startOffset, + straightness, + xUniformity, + yUniformity, + avgPointInterval, + speedPhaseCorrelation, + maxJumpDistance, + overshootRatio + }; + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/ml/TrackRuleEngine.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/ml/TrackRuleEngine.java new file mode 100644 index 0000000..c5857a8 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/ml/TrackRuleEngine.java @@ -0,0 +1,186 @@ +package cloud.tianai.captcha.ml; + +import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack; +import java.util.ArrayList; +import java.util.List; + +public class TrackRuleEngine { + + private final List rules = new ArrayList<>(); + private final TrackFeatureExtractor featureExtractor = new TrackFeatureExtractor(); + + public TrackRuleEngine() { + addDefaultRules(); + } + + public List getRules() { + return rules; + } + + public TrackRuleEngine addRule(Rule rule) { + rules.add(rule); + return this; + } + + public TrackRuleEngine removeRule(String name) { + rules.removeIf(r -> r.name.equals(name)); + return this; + } + + public TrackVerdict evaluate(ImageCaptchaTrack track) { + TrackFeatures features = featureExtractor.extract(track); + return evaluate(features); + } + + public TrackVerdict evaluate(TrackFeatures features) { + double totalScore = 0; + double totalWeight = 0; + List results = new ArrayList<>(rules.size()); + + for (Rule rule : rules) { + RuleResult result = rule.evaluate(features); + results.add(result); + totalScore += result.score * rule.weight; + totalWeight += rule.weight; + } + + double finalScore = totalWeight > 0 ? totalScore / totalWeight : 0; + boolean isHuman = finalScore >= 0.5; + + TrackVerdict verdict = new TrackVerdict(); + verdict.score = finalScore; + verdict.isHuman = isHuman; + verdict.results = results; + verdict.features = features; + return verdict; + } + + private void addDefaultRules() { + rules.add(new Rule("duration_check", 1.0, f -> { + if (f.totalDuration < 300) return new RuleResult(0, "Too fast: " + f.totalDuration + "ms"); + if (f.totalDuration < 500) return new RuleResult(0.3, "Suspiciously fast: " + f.totalDuration + "ms"); + if (f.totalDuration > 30000) return new RuleResult(0.2, "Suspiciously slow: " + f.totalDuration + "ms"); + return new RuleResult(1, "Duration OK: " + f.totalDuration + "ms"); + })); + + rules.add(new Rule("point_count_check", 0.8, f -> { + if (f.totalPoints < 10) return new RuleResult(0, "Too few points: " + f.totalPoints); + if (f.totalPoints > 2000) return new RuleResult(0.1, "Too many points: " + f.totalPoints); + return new RuleResult(1, "Point count OK: " + f.totalPoints); + })); + + rules.add(new Rule("start_offset_check", 0.7, f -> { + if (f.startOffset > 20) return new RuleResult(0.1, "Start offset too large: " + f.startOffset); + return new RuleResult(1, "Start offset OK: " + f.startOffset); + })); + + rules.add(new Rule("speed_variance_check", 1.2, f -> { + if (f.speedVariance < 0.0001) return new RuleResult(0, "Speed too uniform (bot-like)"); + if (f.speedVariance < 0.001) return new RuleResult(0.3, "Speed variance very low"); + return new RuleResult(1, "Speed variance OK: " + f.speedVariance); + })); + + rules.add(new Rule("straightness_check", 1.0, f -> { + if (f.straightness < 0.01) return new RuleResult(1, "Good curvature: " + f.straightness); + if (f.straightness < 0.05) return new RuleResult(0.7, "Moderate curvature: " + f.straightness); + return new RuleResult(0.2, "Too straight (bot-like): " + f.straightness); + })); + + rules.add(new Rule("y_direction_check", 0.6, f -> { + if (f.yDirectionChanges < 2) return new RuleResult(0.2, "Y too stable (bot-like)"); + if (f.yDirectionChanges > 50) return new RuleResult(0.3, "Y too erratic"); + return new RuleResult(1, "Y direction changes OK: " + f.yDirectionChanges); + })); + + rules.add(new Rule("speed_phase_check", 1.0, f -> { + if (f.speedPhaseCorrelation > 0.8) return new RuleResult(0.1, "Speed too uniform across phases"); + if (f.speedPhaseCorrelation > 0.5) return new RuleResult(0.4, "Speed somewhat uniform"); + return new RuleResult(1, "Speed phase variation OK: " + f.speedPhaseCorrelation); + })); + + rules.add(new Rule("max_jump_check", 0.9, f -> { + if (f.maxJumpDistance > 50) return new RuleResult(0, "Jump too large: " + f.maxJumpDistance); + if (f.maxJumpDistance > 30) return new RuleResult(0.4, "Suspicious jump: " + f.maxJumpDistance); + return new RuleResult(1, "Jump distance OK: " + f.maxJumpDistance); + })); + + rules.add(new Rule("pause_check", 0.5, f -> { + if (f.pauses > 5) return new RuleResult(0.3, "Too many pauses: " + f.pauses); + if (f.pauses >= 1) return new RuleResult(1, "Natural pauses: " + f.pauses); + return new RuleResult(0.6, "No pauses (could be bot)"); + })); + + rules.add(new Rule("overshoot_check", 0.8, f -> { + if (f.overshootRatio > 0.3) return new RuleResult(0.2, "Large overshoot: " + f.overshootRatio); + if (f.overshootRatio > 0.05) return new RuleResult(1, "Natural overshoot: " + f.overshootRatio); + return new RuleResult(0.7, "No overshoot (could be bot)"); + })); + + rules.add(new Rule("acceleration_check", 0.7, f -> { + if (f.accelerationVariance < 0.0001) return new RuleResult(0.1, "Acceleration too uniform"); + return new RuleResult(1, "Acceleration variance OK: " + f.accelerationVariance); + })); + + rules.add(new Rule("x_uniformity_check", 0.8, f -> { + if (f.xUniformity < 0.5) return new RuleResult(0.1, "X movement too uniform (bot-like)"); + return new RuleResult(1, "X uniformity OK: " + f.xUniformity); + })); + + rules.add(new Rule("speed_skewness_check", 0.6, f -> { + if (Math.abs(f.speedSkewness) < 0.1) return new RuleResult(0.3, "Speed distribution too symmetric"); + return new RuleResult(1, "Speed skewness OK: " + f.speedSkewness); + })); + + rules.add(new Rule("path_efficiency_check", 0.5, f -> { + if (f.pathEfficiency > 0.98) return new RuleResult(0.2, "Path too efficient (straight line)"); + if (f.pathEfficiency < 0.3) return new RuleResult(0.3, "Path too inefficient (erratic)"); + return new RuleResult(1, "Path efficiency OK: " + f.pathEfficiency); + })); + } + + public static class Rule { + public final String name; + public final double weight; + public final java.util.function.Function evaluator; + + public Rule(String name, double weight, java.util.function.Function evaluator) { + this.name = name; + this.weight = weight; + this.evaluator = evaluator; + } + + public RuleResult evaluate(TrackFeatures features) { + return evaluator.apply(features); + } + } + + public static class RuleResult { + public final double score; + public final String reason; + + public RuleResult(double score, String reason) { + this.score = score; + this.reason = reason; + } + } + + public static class TrackVerdict { + public double score; + public boolean isHuman; + public List results; + public TrackFeatures features; + + public boolean isBot() { + return !isHuman; + } + + public String getSummary() { + StringBuilder sb = new StringBuilder(); + sb.append("Verdict: ").append(isHuman ? "HUMAN" : "BOT").append(" (score=").append(String.format("%.3f", score)).append(")\n"); + for (int i = 0; i < results.size(); i++) { + sb.append(" Rule ").append(i + 1).append(": ").append(results.get(i).reason).append("\n"); + } + return sb.toString(); + } + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/BackgroundShuffler.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/BackgroundShuffler.java new file mode 100644 index 0000000..bd2854b --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/BackgroundShuffler.java @@ -0,0 +1,81 @@ +package cloud.tianai.captcha.obfuscator; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; + +public class BackgroundShuffler implements ImageObfuscator { + + private final Random random = new Random(); + + @Override + public BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config) { + if (!config.isBackgroundShuffleEnabled()) { + return image; + } + + int rows = config.getBackgroundShuffleRows(); + int cols = config.getBackgroundShuffleCols(); + int width = image.getWidth(); + int height = image.getHeight(); + int tileWidth = width / cols; + int tileHeight = height / rows; + + List indices = new ArrayList<>(rows * cols); + for (int i = 0; i < rows * cols; i++) { + indices.add(i); + } + Collections.shuffle(indices, random); + + BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = result.createGraphics(); + + for (int idx = 0; idx < indices.size(); idx++) { + int srcRow = idx / cols; + int srcCol = idx % cols; + int dstRow = indices.get(idx) / cols; + int dstCol = indices.get(idx) % cols; + + int srcX = srcCol * tileWidth; + int srcY = srcRow * tileHeight; + int dstX = dstCol * tileWidth; + int dstY = dstRow * tileHeight; + + BufferedImage tile = image.getSubimage( + Math.min(srcX, width - tileWidth), + Math.min(srcY, height - tileHeight), + tileWidth, + tileHeight + ); + g2d.drawImage(tile, dstX, dstY, null); + } + + g2d.dispose(); + return result; + } + + @Override + public String getName() { + return "background_shuffle"; + } + + public static class ShuffleMetadata { + public List originalIndices; + public int rows; + public int cols; + + public List getRestoreOrder() { + if (originalIndices == null) return Collections.emptyList(); + Integer[] restore = new Integer[originalIndices.size()]; + for (int i = 0; i < originalIndices.size(); i++) { + restore[originalIndices.get(i)] = i; + } + List result = new ArrayList<>(originalIndices.size()); + Collections.addAll(result, restore); + return result; + } + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/CompositeObfuscator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/CompositeObfuscator.java new file mode 100644 index 0000000..fd09dd3 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/CompositeObfuscator.java @@ -0,0 +1,40 @@ +package cloud.tianai.captcha.obfuscator; + +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.List; + +public class CompositeObfuscator implements ImageObfuscator { + + private final List obfuscators = new ArrayList<>(); + + public CompositeObfuscator() { + obfuscators.add(new BackgroundShuffler()); + obfuscators.add(new SinDistorter()); + obfuscators.add(new NoiseInjector()); + } + + public CompositeObfuscator addObfuscator(ImageObfuscator obfuscator) { + obfuscators.add(obfuscator); + return this; + } + + public CompositeObfuscator removeObfuscator(String name) { + obfuscators.removeIf(o -> o.getName().equals(name)); + return this; + } + + @Override + public BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config) { + BufferedImage result = image; + for (ImageObfuscator obfuscator : obfuscators) { + result = obfuscator.obfuscate(result, config); + } + return result; + } + + @Override + public String getName() { + return "composite"; + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/ImageObfuscator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/ImageObfuscator.java new file mode 100644 index 0000000..6923c13 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/ImageObfuscator.java @@ -0,0 +1,10 @@ +package cloud.tianai.captcha.obfuscator; + +import java.awt.image.BufferedImage; + +public interface ImageObfuscator { + + BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config); + + String getName(); +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/NoiseInjector.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/NoiseInjector.java new file mode 100644 index 0000000..c12b33e --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/NoiseInjector.java @@ -0,0 +1,104 @@ +package cloud.tianai.captcha.obfuscator; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.util.Random; + +public class NoiseInjector implements ImageObfuscator { + + private final Random random = new Random(); + + @Override + public BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config) { + BufferedImage result = copyImage(image); + Graphics2D g2d = result.createGraphics(); + int width = result.getWidth(); + int height = result.getHeight(); + + if (config.isNoiseEnabled()) { + injectPointNoise(g2d, width, height, config); + } + + if (config.isLineNoiseEnabled()) { + injectLineNoise(g2d, width, height, config); + } + + if (config.isColorShiftEnabled()) { + g2d.dispose(); + result = applyColorShift(result, config); + g2d.dispose(); + return result; + } + + g2d.dispose(); + return result; + } + + private void injectPointNoise(Graphics2D g2d, int width, int height, ObfuscatorConfig config) { + int count = config.getNoisePointCount(); + float alpha = config.getNoiseAlpha(); + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, alpha)); + + for (int i = 0; i < count; i++) { + int x = random.nextInt(width); + int y = random.nextInt(height); + int rgb = random.nextInt(0xFFFFFF); + g2d.setColor(new Color(rgb)); + int size = 1 + random.nextInt(3); + g2d.fillOval(x, y, size, size); + } + + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f)); + } + + private void injectLineNoise(Graphics2D g2d, int width, int height, ObfuscatorConfig config) { + int count = config.getLineNoiseCount(); + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.3f)); + + for (int i = 0; i < count; i++) { + int x1 = random.nextInt(width); + int y1 = random.nextInt(height); + int x2 = random.nextInt(width); + int y2 = random.nextInt(height); + int rgb = random.nextInt(0xFFFFFF); + g2d.setColor(new Color(rgb)); + g2d.setStroke(new BasicStroke(1 + random.nextFloat())); + g2d.drawLine(x1, y1, x2, y2); + } + + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f)); + } + + private BufferedImage applyColorShift(BufferedImage image, ObfuscatorConfig config) { + int width = image.getWidth(); + int height = image.getHeight(); + int range = config.getColorShiftRange(); + BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int argb = image.getRGB(x, y); + int a = (argb >> 24) & 0xFF; + int r = Math.max(0, Math.min(255, ((argb >> 16) & 0xFF) + random.nextInt(range * 2 + 1) - range)); + int g = Math.max(0, Math.min(255, ((argb >> 8) & 0xFF) + random.nextInt(range * 2 + 1) - range)); + int b = Math.max(0, Math.min(255, (argb & 0xFF) + random.nextInt(range * 2 + 1) - range)); + result.setRGB(x, y, (a << 24) | (r << 16) | (g << 8) | b); + } + } + + return result; + } + + private BufferedImage copyImage(BufferedImage source) { + BufferedImage copy = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_ARGB); + Graphics2D g = copy.createGraphics(); + g.drawImage(source, 0, 0, null); + g.dispose(); + return copy; + } + + @Override + public String getName() { + return "noise_inject"; + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/ObfuscatorConfig.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/ObfuscatorConfig.java new file mode 100644 index 0000000..8234d55 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/ObfuscatorConfig.java @@ -0,0 +1,83 @@ +package cloud.tianai.captcha.obfuscator; + +public class ObfuscatorConfig { + + private boolean backgroundShuffleEnabled = false; + private int backgroundShuffleRows = 3; + private int backgroundShuffleCols = 4; + + private boolean sinDistortEnabled = false; + private double sinAmplitude = 3.0; + private double sinFrequency = 0.05; + + private boolean noiseEnabled = false; + private int noisePointCount = 50; + private float noiseAlpha = 0.3f; + + private boolean lineNoiseEnabled = false; + private int lineNoiseCount = 3; + + private boolean colorShiftEnabled = false; + private int colorShiftRange = 10; + + private boolean blurEnabled = false; + private float blurRadius = 1.0f; + + public static ObfuscatorConfig defaultConfig() { + ObfuscatorConfig config = new ObfuscatorConfig(); + config.setBackgroundShuffleEnabled(true); + config.setSinDistortEnabled(true); + config.setNoiseEnabled(true); + config.setLineNoiseEnabled(true); + config.setColorShiftEnabled(true); + return config; + } + + public static ObfuscatorConfig none() { + return new ObfuscatorConfig(); + } + + public static ObfuscatorConfig high() { + ObfuscatorConfig config = defaultConfig(); + config.setBackgroundShuffleRows(4); + config.setBackgroundShuffleCols(6); + config.setSinAmplitude(5.0); + config.setSinFrequency(0.08); + config.setNoisePointCount(100); + config.setLineNoiseCount(5); + config.setColorShiftRange(20); + config.setBlurEnabled(true); + return config; + } + + public boolean isBackgroundShuffleEnabled() { return backgroundShuffleEnabled; } + public void setBackgroundShuffleEnabled(boolean v) { this.backgroundShuffleEnabled = v; } + public int getBackgroundShuffleRows() { return backgroundShuffleRows; } + public void setBackgroundShuffleRows(int v) { this.backgroundShuffleRows = v; } + public int getBackgroundShuffleCols() { return backgroundShuffleCols; } + public void setBackgroundShuffleCols(int v) { this.backgroundShuffleCols = v; } + public boolean isSinDistortEnabled() { return sinDistortEnabled; } + public void setSinDistortEnabled(boolean v) { this.sinDistortEnabled = v; } + public double getSinAmplitude() { return sinAmplitude; } + public void setSinAmplitude(double v) { this.sinAmplitude = v; } + public double getSinFrequency() { return sinFrequency; } + public void setSinFrequency(double v) { this.sinFrequency = v; } + public boolean isNoiseEnabled() { return noiseEnabled; } + public void setNoiseEnabled(boolean v) { this.noiseEnabled = v; } + public int getNoisePointCount() { return noisePointCount; } + public void setNoisePointCount(int v) { this.noisePointCount = v; } + public float getNoiseAlpha() { return noiseAlpha; } + public void setNoiseAlpha(float v) { this.noiseAlpha = v; } + public boolean isLineNoiseEnabled() { return lineNoiseEnabled; } + public void setLineNoiseEnabled(boolean v) { this.lineNoiseEnabled = v; } + public int getLineNoiseCount() { return lineNoiseCount; } + public void setLineNoiseCount(int v) { this.lineNoiseCount = v; } + public boolean isColorShiftEnabled() { return colorShiftEnabled; } + public void setColorShiftEnabled(boolean v) { this.colorShiftEnabled = v; } + public int getColorShiftRange() { return colorShiftRange; } + public void setColorShiftRange(int v) { this.colorShiftRange = v; } + public boolean isBlurEnabled() { return blurEnabled; } + public void setBlurEnabled(boolean v) { this.blurEnabled = v; } + public float getBlurRadius() { return blurRadius; } + public void setBlurRadius(float v) { this.blurRadius = v; } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/SinDistorter.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/SinDistorter.java new file mode 100644 index 0000000..5d2936e --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/obfuscator/SinDistorter.java @@ -0,0 +1,37 @@ +package cloud.tianai.captcha.obfuscator; + +import java.awt.image.BufferedImage; + +public class SinDistorter implements ImageObfuscator { + + @Override + public BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config) { + if (!config.isSinDistortEnabled()) { + return image; + } + + int width = image.getWidth(); + int height = image.getHeight(); + double amplitude = config.getSinAmplitude(); + double frequency = config.getSinFrequency(); + + BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + + for (int y = 0; y < height; y++) { + double xOffset = amplitude * Math.sin(2 * Math.PI * frequency * y); + for (int x = 0; x < width; x++) { + int srcX = (int) Math.round(x - xOffset); + if (srcX >= 0 && srcX < width) { + result.setRGB(x, y, image.getRGB(srcX, y)); + } + } + } + + return result; + } + + @Override + public String getName() { + return "sin_distort"; + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/risk/IpBlacklist.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/risk/IpBlacklist.java new file mode 100644 index 0000000..43bfc9e --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/risk/IpBlacklist.java @@ -0,0 +1,62 @@ +package cloud.tianai.captcha.risk; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public class IpBlacklist { + + private final ConcurrentHashMap blacklist = new ConcurrentHashMap<>(); + private final ConcurrentHashMap failCounts = new ConcurrentHashMap<>(); + private int maxFailCount = 10; + private long banDurationMs = 3600000; + + public boolean isBanned(String ip) { + Long banUntil = blacklist.get(ip); + if (banUntil == null) { + return false; + } + if (System.currentTimeMillis() > banUntil) { + blacklist.remove(ip); + failCounts.remove(ip); + return false; + } + return true; + } + + public void recordFail(String ip) { + int count = failCounts.merge(ip, 1, Integer::sum); + if (count >= maxFailCount) { + ban(ip); + } + } + + public void ban(String ip) { + blacklist.put(ip, System.currentTimeMillis() + banDurationMs); + } + + public void ban(String ip, long durationMs) { + blacklist.put(ip, System.currentTimeMillis() + durationMs); + } + + public void unban(String ip) { + blacklist.remove(ip); + failCounts.remove(ip); + } + + public void recordSuccess(String ip) { + failCounts.remove(ip); + } + + public Set getBannedIps() { + return blacklist.keySet(); + } + + public int getFailCount(String ip) { + return failCounts.getOrDefault(ip, 0); + } + + public void configure(int maxFailCount, long banDurationMs) { + this.maxFailCount = maxFailCount; + this.banDurationMs = banDurationMs; + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/risk/RateLimiter.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/risk/RateLimiter.java new file mode 100644 index 0000000..f3da8d0 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/risk/RateLimiter.java @@ -0,0 +1,79 @@ +package cloud.tianai.captcha.risk; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +public class RateLimiter { + + private final ConcurrentHashMap windows = new ConcurrentHashMap<>(); + private long windowSizeMs; + private int maxRequests; + private final CleanupThread cleanupThread; + + public RateLimiter(long windowSizeMs, int maxRequests) { + this.windowSizeMs = windowSizeMs; + this.maxRequests = maxRequests; + this.cleanupThread = new CleanupThread(); + this.cleanupThread.setDaemon(true); + this.cleanupThread.start(); + } + + public boolean allow(String key) { + SlidingWindow window = windows.computeIfAbsent(key, k -> new SlidingWindow(windowSizeMs)); + return window.increment() <= maxRequests; + } + + public int getCurrentCount(String key) { + SlidingWindow window = windows.get(key); + return window != null ? (int) window.count.get() : 0; + } + + public void reset(String key) { + windows.remove(key); + } + + public void configure(long windowSizeMs, int maxRequests) { + this.windowSizeMs = windowSizeMs; + this.maxRequests = maxRequests; + } + + private class SlidingWindow { + private final AtomicLong count = new AtomicLong(0); + private volatile long startTime; + + SlidingWindow(long windowSizeMs) { + this.startTime = System.currentTimeMillis(); + } + + long increment() { + long now = System.currentTimeMillis(); + if (now - startTime > windowSizeMs) { + synchronized (this) { + if (now - startTime > windowSizeMs) { + startTime = now; + count.set(0); + } + } + } + return count.incrementAndGet(); + } + } + + private class CleanupThread extends Thread { + @Override + public void run() { + while (!Thread.currentThread().isInterrupted()) { + try { + Thread.sleep(60000); + long now = System.currentTimeMillis(); + windows.entrySet().removeIf(entry -> + now - entry.getValue().startTime > windowSizeMs * 2 + ); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/risk/RiskEngine.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/risk/RiskEngine.java new file mode 100644 index 0000000..e5885d4 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/risk/RiskEngine.java @@ -0,0 +1,71 @@ +package cloud.tianai.captcha.risk; + +public class RiskEngine { + + private final RateLimiter rateLimiter; + private final IpBlacklist ipBlacklist; + private boolean rateLimitEnabled = true; + private boolean ipBlacklistEnabled = true; + + public RiskEngine() { + this.rateLimiter = new RateLimiter(60000, 60); + this.ipBlacklist = new IpBlacklist(); + } + + public RiskEngine(RateLimiter rateLimiter, IpBlacklist ipBlacklist) { + this.rateLimiter = rateLimiter; + this.ipBlacklist = ipBlacklist; + } + + public RiskResult check(String ip, String captchaId) { + RiskResult result = new RiskResult(); + + if (ipBlacklistEnabled && ipBlacklist.isBanned(ip)) { + result.allowed = false; + result.reason = "ip_banned"; + return result; + } + + if (rateLimitEnabled && !rateLimiter.allow("ip:" + ip)) { + result.allowed = false; + result.reason = "rate_limit_ip"; + return result; + } + + if (rateLimitEnabled && !rateLimiter.allow("id:" + captchaId)) { + result.allowed = false; + result.reason = "rate_limit_id"; + return result; + } + + result.allowed = true; + result.reason = "ok"; + return result; + } + + public void recordSuccess(String ip) { + if (ipBlacklistEnabled) { + ipBlacklist.recordSuccess(ip); + } + } + + public void recordFail(String ip) { + if (ipBlacklistEnabled) { + ipBlacklist.recordFail(ip); + } + } + + public RateLimiter getRateLimiter() { return rateLimiter; } + public IpBlacklist getIpBlacklist() { return ipBlacklist; } + + public void setRateLimitEnabled(boolean v) { this.rateLimitEnabled = v; } + public void setIpBlacklistEnabled(boolean v) { this.ipBlacklistEnabled = v; } + + public static class RiskResult { + public boolean allowed; + public String reason; + + public boolean isAllowed() { return allowed; } + public String getReason() { return reason; } + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/site/SiteConfig.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/site/SiteConfig.java new file mode 100644 index 0000000..1dc48ca --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/site/SiteConfig.java @@ -0,0 +1,53 @@ +package cloud.tianai.captcha.site; + +import java.util.Set; + +public class SiteConfig { + + private String siteId; + private String siteKey; + private String secretKey; + private String name; + private Set domains; + private boolean enabled = true; + private Set allowedTypes; + private String level = "normal"; + private int maxQps = 100; + private long captchaExpireMs = 120000; + private float tolerant = 0.02f; + private boolean trackValidationEnabled = true; + private double trackHumanThreshold = 0.5; + private boolean obfuscationEnabled = true; + private boolean encryptionEnabled = true; + + public String getSiteId() { return siteId; } + public void setSiteId(String v) { this.siteId = v; } + public String getSiteKey() { return siteKey; } + public void setSiteKey(String v) { this.siteKey = v; } + public String getSecretKey() { return secretKey; } + public void setSecretKey(String v) { this.secretKey = v; } + public String getName() { return name; } + public void setName(String v) { this.name = v; } + public Set getDomains() { return domains; } + public void setDomains(Set v) { this.domains = v; } + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean v) { this.enabled = v; } + public Set getAllowedTypes() { return allowedTypes; } + public void setAllowedTypes(Set v) { this.allowedTypes = v; } + public String getLevel() { return level; } + public void setLevel(String v) { this.level = v; } + public int getMaxQps() { return maxQps; } + public void setMaxQps(int v) { this.maxQps = v; } + public long getCaptchaExpireMs() { return captchaExpireMs; } + public void setCaptchaExpireMs(long v) { this.captchaExpireMs = v; } + public float getTolerant() { return tolerant; } + public void setTolerant(float v) { this.tolerant = v; } + public boolean isTrackValidationEnabled() { return trackValidationEnabled; } + public void setTrackValidationEnabled(boolean v) { this.trackValidationEnabled = v; } + public double getTrackHumanThreshold() { return trackHumanThreshold; } + public void setTrackHumanThreshold(double v) { this.trackHumanThreshold = v; } + public boolean isObfuscationEnabled() { return obfuscationEnabled; } + public void setObfuscationEnabled(boolean v) { this.obfuscationEnabled = v; } + public boolean isEncryptionEnabled() { return encryptionEnabled; } + public void setEncryptionEnabled(boolean v) { this.encryptionEnabled = v; } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/site/SiteManager.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/site/SiteManager.java new file mode 100644 index 0000000..824e929 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/site/SiteManager.java @@ -0,0 +1,83 @@ +package cloud.tianai.captcha.site; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public class SiteManager { + + private final ConcurrentHashMap sitesBySiteKey = new ConcurrentHashMap<>(); + private final ConcurrentHashMap sitesBySiteId = new ConcurrentHashMap<>(); + private final SecureRandom random = new SecureRandom(); + + public SiteConfig registerSite(String name, Set domains, Set allowedTypes) { + SiteConfig config = new SiteConfig(); + config.setSiteId(generateId()); + config.setSiteKey(generateKey()); + config.setSecretKey(generateKey()); + config.setName(name); + config.setDomains(domains); + config.setAllowedTypes(allowedTypes); + + sitesBySiteKey.put(config.getSiteKey(), config); + sitesBySiteId.put(config.getSiteId(), config); + return config; + } + + public SiteConfig getSiteBySiteKey(String siteKey) { + return sitesBySiteKey.get(siteKey); + } + + public SiteConfig getSiteBySiteId(String siteId) { + return sitesBySiteId.get(siteId); + } + + public boolean validateSiteKey(String siteKey, String domain) { + SiteConfig config = sitesBySiteKey.get(siteKey); + if (config == null || !config.isEnabled()) { + return false; + } + if (domain != null && config.getDomains() != null && !config.getDomains().isEmpty()) { + return config.getDomains().contains(domain) || config.getDomains().contains("*"); + } + return true; + } + + public boolean validateSecretKey(String siteKey, String secretKey) { + SiteConfig config = sitesBySiteKey.get(siteKey); + return config != null && config.getSecretKey().equals(secretKey); + } + + public boolean isTypeAllowed(String siteKey, String type) { + SiteConfig config = sitesBySiteKey.get(siteKey); + if (config == null) return false; + if (config.getAllowedTypes() == null || config.getAllowedTypes().isEmpty()) return true; + return config.getAllowedTypes().contains(type); + } + + public void removeSite(String siteKey) { + SiteConfig config = sitesBySiteKey.remove(siteKey); + if (config != null) { + sitesBySiteId.remove(config.getSiteId()); + } + } + + public Collection getAllSites() { + return Collections.unmodifiableCollection(sitesBySiteKey.values()); + } + + private String generateId() { + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String generateKey() { + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/site/TokenService.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/site/TokenService.java new file mode 100644 index 0000000..fae2b20 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/site/TokenService.java @@ -0,0 +1,44 @@ +package cloud.tianai.captcha.site; + +import cloud.tianai.captcha.cache.CacheStore; +import cloud.tianai.captcha.common.AnyMap; + +import java.util.Base64; +import java.util.concurrent.TimeUnit; + +public class TokenService { + + private final CacheStore cacheStore; + private final String tokenPrefix = "captcha:token:"; + private long tokenExpireMs = 120000; + + public TokenService(CacheStore cacheStore) { + this.cacheStore = cacheStore; + } + + public String generateToken(String siteKey, String captchaId) { + byte[] tokenBytes = new byte[32]; + new java.security.SecureRandom().nextBytes(tokenBytes); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + + AnyMap data = new AnyMap(); + data.put("siteKey", siteKey); + data.put("captchaId", captchaId); + data.put("createdAt", System.currentTimeMillis()); + + cacheStore.setCache(tokenPrefix + token, data, tokenExpireMs, TimeUnit.MILLISECONDS); + return token; + } + + public AnyMap consumeToken(String token) { + return cacheStore.getAndRemoveCache(tokenPrefix + token); + } + + public boolean validateToken(String token) { + return cacheStore.getCache(tokenPrefix + token) != null; + } + + public void setTokenExpireMs(long tokenExpireMs) { + this.tokenExpireMs = tokenExpireMs; + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/validator/impl/EnhancedTrackValidator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/validator/impl/EnhancedTrackValidator.java new file mode 100644 index 0000000..60912d9 --- /dev/null +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/validator/impl/EnhancedTrackValidator.java @@ -0,0 +1,67 @@ +package cloud.tianai.captcha.validator.impl; + +import cloud.tianai.captcha.common.AnyMap; +import cloud.tianai.captcha.common.response.ApiResponse; +import cloud.tianai.captcha.common.response.ApiResponseStatusConstant; +import cloud.tianai.captcha.ml.TrackRuleEngine; +import cloud.tianai.captcha.ml.TrackRuleEngine.TrackVerdict; +import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack; + +public class EnhancedTrackValidator extends SimpleImageCaptchaValidator { + + public static final int TRACK_CHECK_FAIL_CODE = 4002; + public static final int TRACK_EMPTY_CODE = 4003; + + private TrackRuleEngine ruleEngine; + private double humanThreshold = 0.5; + private boolean enabled = true; + + public EnhancedTrackValidator() { + this.ruleEngine = new TrackRuleEngine(); + } + + public EnhancedTrackValidator(float defaultTolerant) { + super(defaultTolerant); + this.ruleEngine = new TrackRuleEngine(); + } + + @Override + public ApiResponse afterValid(Boolean basicValid, ImageCaptchaTrack imageCaptchaTrack, + AnyMap captchaValidData, Float tolerant, String type) { + if (!basicValid) { + return ApiResponse.ofMessage(ApiResponseStatusConstant.BASIC_CHECK_FAIL); + } + + if (!enabled) { + return ApiResponse.ofSuccess(); + } + + if (imageCaptchaTrack.getTrackList() == null || imageCaptchaTrack.getTrackList().isEmpty()) { + return ApiResponse.of(TRACK_EMPTY_CODE, "track_empty", null); + } + + TrackVerdict verdict = ruleEngine.evaluate(imageCaptchaTrack); + + if (verdict.isBot()) { + return ApiResponse.of(TRACK_CHECK_FAIL_CODE, "track_check_fail", null); + } + + return ApiResponse.ofSuccess(); + } + + public void setRuleEngine(TrackRuleEngine ruleEngine) { + this.ruleEngine = ruleEngine; + } + + public void setHumanThreshold(double humanThreshold) { + this.humanThreshold = humanThreshold; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public TrackRuleEngine getRuleEngine() { + return ruleEngine; + } +} diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/ambulance.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/ambulance.png new file mode 100644 index 0000000..8e5d36f Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/ambulance.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/apple.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/apple.png new file mode 100644 index 0000000..bb51b6c Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/apple.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/banana.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/banana.png new file mode 100644 index 0000000..4196c07 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/banana.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/basketball.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/basketball.png new file mode 100644 index 0000000..1262e88 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/basketball.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/bear.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/bear.png new file mode 100644 index 0000000..8a634a3 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/bear.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/billiards.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/billiards.png new file mode 100644 index 0000000..f6db113 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/billiards.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/boxing.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/boxing.png new file mode 100644 index 0000000..78d701b Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/boxing.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/bus.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/bus.png new file mode 100644 index 0000000..11b1203 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/bus.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/camera.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/camera.png new file mode 100644 index 0000000..edcc4f7 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/camera.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/car.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/car.png new file mode 100644 index 0000000..5ec7790 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/car.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/cat.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/cat.png new file mode 100644 index 0000000..8861b85 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/cat.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/cd.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/cd.png new file mode 100644 index 0000000..a14a578 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/cd.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/cherry.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/cherry.png new file mode 100644 index 0000000..20b742f Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/cherry.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/computer-mouse.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/computer-mouse.png new file mode 100644 index 0000000..8860ae4 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/computer-mouse.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/desktop.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/desktop.png new file mode 100644 index 0000000..01e967f Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/desktop.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/dog.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/dog.png new file mode 100644 index 0000000..9b2ee7b Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/dog.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/firetruck.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/firetruck.png new file mode 100644 index 0000000..a223592 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/firetruck.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/fox.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/fox.png new file mode 100644 index 0000000..066ea85 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/fox.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/grapes.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/grapes.png new file mode 100644 index 0000000..838fc88 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/grapes.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/hamster.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/hamster.png new file mode 100644 index 0000000..8d9841a Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/hamster.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/keyboard.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/keyboard.png new file mode 100644 index 0000000..2736411 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/keyboard.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/laptop.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/laptop.png new file mode 100644 index 0000000..287a264 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/laptop.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/lemon.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/lemon.png new file mode 100644 index 0000000..b5090a9 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/lemon.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/mouse.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/mouse.png new file mode 100644 index 0000000..98d1637 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/mouse.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/orange.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/orange.png new file mode 100644 index 0000000..a8d34a2 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/orange.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/panda.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/panda.png new file mode 100644 index 0000000..e1de995 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/panda.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/phone.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/phone.png new file mode 100644 index 0000000..e1a4204 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/phone.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/pingpong.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/pingpong.png new file mode 100644 index 0000000..b090650 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/pingpong.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/police.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/police.png new file mode 100644 index 0000000..404d18b Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/police.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/printer.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/printer.png new file mode 100644 index 0000000..98c9143 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/printer.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/rabbit.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/rabbit.png new file mode 100644 index 0000000..b86c3c9 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/rabbit.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/racecar.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/racecar.png new file mode 100644 index 0000000..a516fd0 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/racecar.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/soccer.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/soccer.png new file mode 100644 index 0000000..e6027dc Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/soccer.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/strawberry.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/strawberry.png new file mode 100644 index 0000000..7c11338 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/strawberry.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/suv.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/suv.png new file mode 100644 index 0000000..c782e99 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/suv.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/taxi.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/taxi.png new file mode 100644 index 0000000..3fdb5b4 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/taxi.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/tennis.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/tennis.png new file mode 100644 index 0000000..757fa96 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/tennis.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/trophy.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/trophy.png new file mode 100644 index 0000000..cdd965e Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/trophy.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/volleyball.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/volleyball.png new file mode 100644 index 0000000..e050b99 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/volleyball.png differ diff --git a/tianai-captcha/src/main/resources/META-INF/captcha-icons/watermelon.png b/tianai-captcha/src/main/resources/META-INF/captcha-icons/watermelon.png new file mode 100644 index 0000000..d7839d8 Binary files /dev/null and b/tianai-captcha/src/main/resources/META-INF/captcha-icons/watermelon.png differ diff --git a/tools/icon-render/RenderIcons.java b/tools/icon-render/RenderIcons.java new file mode 100644 index 0000000..b62a5c3 --- /dev/null +++ b/tools/icon-render/RenderIcons.java @@ -0,0 +1,132 @@ +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * One-off tool: pre-render emoji icons to PNG assets for IconClickImageCaptchaGenerator. + * Must run on a machine that has a color/outline emoji font (e.g. Windows Segoe UI Emoji). + * Output: META-INF/captcha-icons/.png (96x96, white backing disc + glyph). + * + * Usage: java tools/icon-render/RenderIcons.java + */ +public class RenderIcons { + + private static final int CANVAS = 96; + private static final float GLYPH_SIZE = 60f; + + private static final Map ICONS = new LinkedHashMap<>() {{ + // fruits + put("apple", "\uD83C\uDF4E"); + put("banana", "\uD83C\uDF4C"); + put("cherry", "\uD83C\uDF52"); + put("grapes", "\uD83C\uDF47"); + put("lemon", "\uD83C\uDF4B"); + put("orange", "\uD83C\uDF4A"); + put("strawberry", "\uD83C\uDF53"); + put("watermelon", "\uD83C\uDF49"); + // animals + put("dog", "\uD83D\uDC36"); + put("cat", "\uD83D\uDC31"); + put("mouse", "\uD83D\uDC2D"); + put("hamster", "\uD83D\uDC39"); + put("rabbit", "\uD83D\uDC30"); + put("fox", "\uD83E\uDD8A"); + put("bear", "\uD83D\uDC3B"); + put("panda", "\uD83D\uDC28"); + // sports + put("soccer", "\u26BD"); + put("basketball", "\uD83C\uDFC0"); + put("tennis", "\uD83C\uDFBE"); + put("volleyball", "\uD83C\uDFD0"); + put("billiards", "\uD83C\uDFB1"); + put("pingpong", "\uD83C\uDFD3"); + put("trophy", "\uD83C\uDFC6"); + put("boxing", "\uD83E\uDD4A"); + // vehicles + put("car", "\uD83D\uDE97"); + put("taxi", "\uD83D\uDE95"); + put("suv", "\uD83D\uDE99"); + put("bus", "\uD83D\uDE8C"); + put("racecar", "\uD83C\uDFCE"); + put("police", "\uD83D\uDE93"); + put("ambulance", "\uD83D\uDE91"); + put("firetruck", "\uD83D\uDE92"); + // tech + put("phone", "\uD83D\uDCF1"); + put("laptop", "\uD83D\uDCBB"); + put("desktop", "\uD83D\uDDA5"); + put("printer", "\uD83D\uDDA8"); + put("keyboard", "\u2328"); + put("computer-mouse", "\uD83D\uDDB1"); + put("cd", "\uD83D\uDCBF"); + put("camera", "\uD83D\uDCF7"); + }}; + + public static void main(String[] args) throws IOException { + Path outDir = Paths.get(args.length > 0 ? args[0] : "."); + Files.createDirectories(outDir); + + GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment(); + Font emojiFont = pickFont(ge); + System.out.println("Using font: " + emojiFont.getFontName()); + + int failures = 0; + for (Map.Entry e : ICONS.entrySet()) { + BufferedImage img = render(e.getValue(), emojiFont); + File out = outDir.resolve(e.getKey() + ".png").toFile(); + ImageIO.write(img, "png", out); + if (emojiFont.canDisplayUpTo(e.getValue()) != -1) { + System.out.println("[WARN] partial glyph coverage: " + e.getKey()); + failures++; + } + System.out.println("OK " + out.getName()); + } + System.out.println("Done. failures(warn)=" + failures); + } + + private static Font pickFont(GraphicsEnvironment ge) { + for (String name : new String[]{"Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", "EmojiOne"}) { + for (Font f : ge.getAllFonts()) { + if (f.getFontName().equalsIgnoreCase(name)) { + return f.deriveFont(Font.PLAIN, GLYPH_SIZE); + } + } + } + System.out.println("[WARN] no known emoji font found, falling back to Dialog"); + return new Font(Font.DIALOG, Font.PLAIN, (int) GLYPH_SIZE); + } + + private static BufferedImage render(String emoji, Font font) { + BufferedImage img = new BufferedImage(CANVAS, CANVAS, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + try { + // translucent white backing disc -> keeps icons readable on any photo background + g.setColor(new Color(255, 255, 255, 216)); + g.fillOval(2, 2, CANVAS - 5, CANVAS - 5); + g.setColor(new Color(0, 0, 0, 46)); + g.setStroke(new BasicStroke(2f)); + g.drawOval(3, 3, CANVAS - 7, CANVAS - 7); + + g.setColor(Color.BLACK); + g.setFont(font); + FontMetrics fm = g.getFontMetrics(); + int x = (CANVAS - fm.stringWidth(emoji)) / 2; + int y = (CANVAS - fm.getHeight()) / 2 + fm.getAscent(); + g.drawString(emoji, x, y); + } finally { + g.dispose(); + } + return img; + } +}