Merge pull request #167 from rensumo/main

feat: 新增捐赠账号池并完善认证参数解码/失败熔断机制
This commit is contained in:
qaiu
2026-02-22 18:00:32 +08:00
committed by GitHub
15 changed files with 886 additions and 24 deletions

1
.gitignore vendored
View File

@@ -56,6 +56,7 @@ test-filelist.java
*.temp *.temp
*.log *.log
*.bak *.bak
**/secret.yml
*.swp *.swp
*.swo *.swo
*~ *~

View File

@@ -179,6 +179,18 @@ GET /parser?url={分享链接}&pwd={密码}&auth={加密后的认证参数}
> 💡 提示Web 界面已内置认证配置功能,可自动处理加密过程,无需手动构造参数。 > 💡 提示Web 界面已内置认证配置功能,可自动处理加密过程,无需手动构造参数。
> [可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html) > [可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
#### 密钥作用说明
- `server.authEncryptKey`
- 作用:用于 `auth` 参数的 AES 加解密
- 要求16位AES-128
- `server.donatedAccountFailureTokenSignKey`
- 作用:用于“捐赠账号失败计数 token”的 HMAC 签名/验签
- 目的:防止客户端伪造失败计数请求
- 建议:使用高强度随机字符串,且不要与 `authEncryptKey` 相同
### 特殊说明 ### 特殊说明
- 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值 - 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值

View File

@@ -318,6 +318,7 @@ public class RouterHandlerFactory implements BaseHttpApi {
// 只处理POST/PUT/PATCH等有body的请求方法避免GET请求读取body导致"Request has already been read"错误 // 只处理POST/PUT/PATCH等有body的请求方法避免GET请求读取body导致"Request has already been read"错误
String httpMethod = ctx.request().method().name(); String httpMethod = ctx.request().method().name();
if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod)) if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod))
&& ctx.parsedHeaders() != null && ctx.parsedHeaders().contentType() != null
&& HttpHeaderValues.APPLICATION_JSON.toString().equals(ctx.parsedHeaders().contentType().value()) && HttpHeaderValues.APPLICATION_JSON.toString().equals(ctx.parsedHeaders().contentType().value())
&& ctx.body() != null && ctx.body().asJsonObject() != null) { && ctx.body() != null && ctx.body().asJsonObject() != null) {
JsonObject body = ctx.body().asJsonObject(); JsonObject body = ctx.body().asJsonObject();
@@ -340,8 +341,12 @@ public class RouterHandlerFactory implements BaseHttpApi {
}); });
} }
} else if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod)) } else if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod))
&& ctx.body() != null) { && ctx.body() != null && ctx.body().length() > 0) {
try {
queryParams.addAll(ParamUtil.paramsToMap(ctx.body().asString())); queryParams.addAll(ParamUtil.paramsToMap(ctx.body().asString()));
} catch (Exception e) {
LOGGER.debug("Failed to parse body as params: {}", e.getMessage());
}
} }
// 解析其他参数 // 解析其他参数
@@ -360,6 +365,12 @@ public class RouterHandlerFactory implements BaseHttpApi {
parameterValueList.put(k, ctx.request()); parameterValueList.put(k, ctx.request());
} else if (HttpServerResponse.class.getName().equals(v.getRight().getName())) { } else if (HttpServerResponse.class.getName().equals(v.getRight().getName())) {
parameterValueList.put(k, ctx.response()); parameterValueList.put(k, ctx.response());
} else if (JsonObject.class.getName().equals(v.getRight().getName())) {
if (ctx.body() != null && ctx.body().asJsonObject() != null) {
parameterValueList.put(k, ctx.body().asJsonObject());
} else {
parameterValueList.put(k, new JsonObject());
}
} else if (parameterValueList.get(k) == null } else if (parameterValueList.get(k) == null
&& CommonUtil.matchRegList(entityPackagesReg.getList(), v.getRight().getName())) { && CommonUtil.matchRegList(entityPackagesReg.getList(), v.getRight().getName())) {
// 绑定实体类 // 绑定实体类
@@ -374,6 +385,17 @@ public class RouterHandlerFactory implements BaseHttpApi {
}); });
// 调用handle 获取响应对象 // 调用handle 获取响应对象
Object[] parameterValueArray = parameterValueList.values().toArray(new Object[0]); Object[] parameterValueArray = parameterValueList.values().toArray(new Object[0]);
// 打印调试信息,确认参数注入的情况
if (LOGGER.isDebugEnabled() && method.getName().equals("donateAccount")) {
LOGGER.debug("donateAccount parameter list:");
int i = 0;
for (Map.Entry<String, Object> entry : parameterValueList.entrySet()) {
LOGGER.debug("Param [{}]: {} = {}", i++, entry.getKey(),
entry.getValue() != null ? entry.getValue().toString() : "null");
}
}
try { try {
// 反射调用 // 反射调用
Object data = ReflectionUtil.invokeWithArguments(method, instance, parameterValueArray); Object data = ReflectionUtil.invokeWithArguments(method, instance, parameterValueArray);

View File

@@ -48,6 +48,9 @@ public class RouterVerticle extends AbstractVerticle {
} else { } else {
options = new HttpServerOptions(); options = new HttpServerOptions();
} }
// 绑定到 0.0.0.0 以允许外部访问
options.setHost("0.0.0.0");
options.setPort(port); options.setPort(port);
server = vertx.createHttpServer(options); server = vertx.createHttpServer(options);

View File

@@ -36,6 +36,22 @@ URL解码 → Base64解码 → AES解密 → JSON对象
- **密钥长度**: 16位128位 - **密钥长度**: 16位128位
- **默认密钥**: `nfd_auth_key2026`(可在 `app-dev.yml` 中通过 `server.authEncryptKey` 配置) - **默认密钥**: `nfd_auth_key2026`(可在 `app-dev.yml` 中通过 `server.authEncryptKey` 配置)
### 密钥作用说明(重要)
当前系统中涉及两类不同用途的密钥:
1. `server.authEncryptKey`
- 用途:加解密 `auth` 参数(前端/调用方传入的认证信息)
- 影响范围:`/parser``/json/parser``/v2/linkInfo` 等接口中的 `auth` 参数
- 注意:这是 **AES 对称加密密钥**,要求 16 位
2. `server.donatedAccountFailureTokenSignKey`
- 用途:签名和验签“捐赠账号失败计数 token”用于防伪造、失败计数
- 影响范围:捐赠账号失败计数与自动失效逻辑
- 注意:这是 **HMAC 签名密钥**,与 `authEncryptKey` 已解耦,建议使用高强度随机字符串
> 建议:生产环境务必同时自定义这两个密钥,且不要设置为相同值。
## JSON 模型定义 ## JSON 模型定义
### AuthParam 对象 ### AuthParam 对象
@@ -301,14 +317,25 @@ if (auths != null) {
## 配置说明 ## 配置说明
`app-dev.yml` 中配置加密密钥: `app-dev.yml` 中配置密钥:
```yaml ```yaml
server: server:
# auth参数加密密钥16位AES密钥 # auth参数加密密钥16位AES密钥
authEncryptKey: 'your_custom_key16' authEncryptKey: 'your_custom_key16'
# 捐赠账号失败计数token签名密钥HMAC
# 建议使用较长随机字符串,并与 authEncryptKey 不同
donatedAccountFailureTokenSignKey: 'your_random_hmac_sign_key'
``` ```
### 密钥管理建议
- 不要在公开仓库提交生产密钥
- 建议通过环境变量或私有配置注入
- 调整 `authEncryptKey` 会影响 `auth` 参数兼容性
- 调整 `donatedAccountFailureTokenSignKey` 会使已签发的失败计数 token 失效(短期可接受)
## 更新日志 ## 更新日志
- **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型 - **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型

View File

@@ -2,7 +2,7 @@
<div id="app" v-cloak :class="{ 'dark-theme': isDarkMode }"> <div id="app" v-cloak :class="{ 'dark-theme': isDarkMode }">
<!-- <el-dialog <!-- <el-dialog
v-model="showRiskDialog" v-model="showRiskDialog"
title="使用本网站您应同意" title="使用本网站您应同意"
width="300px" width="300px"
:close-on-click-modal="false" :close-on-click-modal="false"
:close-on-press-escape="false" :close-on-press-escape="false"
@@ -35,6 +35,10 @@
<i class="fas fa-server feedback-icon"></i> <i class="fas fa-server feedback-icon"></i>
部署 部署
</a> </a>
<a href="javascript:void(0)" class="feedback-link mini donate-link" @click="showDonateDialog = true">
<i class="fas fa-gift feedback-icon" style="color: #e74c3c;"></i>
捐赠账号
</a>
</div> </div>
<el-row :gutter="20" style="margin-left: 0; margin-right: 0;"> <el-row :gutter="20" style="margin-left: 0; margin-right: 0;">
<el-card class="box-card"> <el-card class="box-card">
@@ -352,6 +356,102 @@
<!-- </el-input>--> <!-- </el-input>-->
<!-- </div>--> <!-- </div>-->
<!-- 捐赠账号弹窗 -->
<el-dialog
v-model="showDonateDialog"
title="🎁 捐赠网盘账号"
width="550px"
:close-on-click-modal="false"
@open="loadDonateAccountCounts">
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 15px;">
<template #title>
捐赠您的网盘 Cookie/Token解析时将从所有捐赠账号中随机选择使用分摊请求压力
</template>
</el-alert>
<!-- 已捐赠账号数量统计 -->
<div v-if="donateAccountCounts.active.total + donateAccountCounts.inactive.total > 0" style="margin-bottom: 16px;">
<el-divider content-position="left">
当前账号池活跃 {{ donateAccountCounts.active.total }} / 失效 {{ donateAccountCounts.inactive.total }}
</el-divider>
<div style="margin-bottom: 8px;">
<el-tag type="success" style="margin-right: 8px;">活跃账号</el-tag>
<el-tag
v-for="(count, panType) in donateAccountCounts.active"
:key="`active-${panType}`"
v-show="panType !== 'total'"
type="success"
style="margin-right: 6px; margin-bottom: 4px;">
{{ getPanDisplayName(panType) }}: {{ count }}
</el-tag>
</div>
<div>
<el-tag type="danger" style="margin-right: 8px;">失效账号</el-tag>
<el-tag
v-for="(count, panType) in donateAccountCounts.inactive"
:key="`inactive-${panType}`"
v-show="panType !== 'total'"
type="danger"
style="margin-right: 6px; margin-bottom: 4px;">
{{ getPanDisplayName(panType) }}: {{ count }}
</el-tag>
</div>
</div>
<div v-else style="margin-bottom: 16px; text-align: center; color: #999;">
暂无捐赠账号成为第一个捐赠者吧
</div>
<el-form :model="donateConfig" label-width="100px" size="default">
<el-form-item label="网盘类型" required>
<el-select v-model="donateConfig.panType" placeholder="请选择网盘类型" style="width: 100%" @change="onDonatePanTypeChange">
<el-option-group label="必须认证">
<el-option label="夸克网盘 (QK)" value="QK" />
<el-option label="UC网盘 (UC)" value="UC" />
</el-option-group>
<el-option-group label="大文件需认证">
<el-option label="小飞机网盘 (FJ)" value="FJ" />
<el-option label="蓝奏优享 (IZ)" value="IZ" />
<el-option label="123云盘 (YE)" value="YE" />
</el-option-group>
</el-select>
</el-form-item>
<el-form-item label="认证类型">
<el-select v-model="donateConfig.authType" placeholder="请选择认证类型" style="width: 100%">
<el-option
v-for="opt in getDonateAuthTypes()"
:key="opt.value"
:label="opt.label"
:value="opt.value" />
</el-select>
</el-form-item>
<el-form-item v-if="donateConfig.authType === 'password'" label="用户名">
<el-input v-model="donateConfig.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item v-if="donateConfig.authType === 'password'" label="密码">
<el-input v-model="donateConfig.password" type="password" show-password placeholder="请输入密码" />
</el-form-item>
<el-form-item v-if="donateConfig.authType && donateConfig.authType !== 'password'" label="Token/Cookie">
<el-input
v-model="donateConfig.token"
type="textarea"
:rows="3"
placeholder="粘贴 Cookie 或 Token从浏览器开发者工具获取" />
</el-form-item>
<el-form-item label="备注(可选)">
<el-input v-model="donateConfig.remark" placeholder="如:我的夸克小号" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDonateDialog = false">关闭</el-button>
<el-button type="primary" @click="submitDonateAccount" :loading="donateSubmitting">
<el-icon><Plus /></el-icon> 捐赠此账号
</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -436,7 +536,24 @@ export default {
ext5: '' ext5: ''
}, },
// 所有网盘的认证配置 { panType: config } // 所有网盘的认证配置 { panType: config }
allAuthConfigs: {} allAuthConfigs: {},
// 捐赠账号相关
showDonateDialog: false,
donateSubmitting: false,
donateConfig: {
panType: '',
authType: 'cookie',
username: '',
password: '',
token: '',
remark: ''
},
// 捐赠账号数量统计
donateAccountCounts: {
active: { total: 0 },
inactive: { total: 0 }
}
} }
}, },
computed: { computed: {
@@ -460,6 +577,7 @@ export default {
if (url.includes('drive.uc.cn') || url.includes('fast.uc.cn')) return 'UC' if (url.includes('drive.uc.cn') || url.includes('fast.uc.cn')) return 'UC'
if (url.includes('feijipan.com') || url.includes('feijihe.com') || url.includes('xiaofeiyang.com')) return 'FJ' if (url.includes('feijipan.com') || url.includes('feijihe.com') || url.includes('xiaofeiyang.com')) return 'FJ'
if (url.includes('ilanzou.com') || url.includes('lanzouv.com')) return 'IZ' if (url.includes('ilanzou.com') || url.includes('lanzouv.com')) return 'IZ'
if (url.includes('123pan.com') || url.includes('123684.com') || url.includes('123865.com')) return 'YE'
return '' return ''
}, },
@@ -469,7 +587,8 @@ export default {
'QK': '夸克网盘', 'QK': '夸克网盘',
'UC': 'UC网盘', 'UC': 'UC网盘',
'FJ': '小飞机网盘', 'FJ': '小飞机网盘',
'IZ': '蓝奏优享' 'IZ': '蓝奏优享',
'YE': '123云盘'
} }
return names[panType] || panType return names[panType] || panType
}, },
@@ -663,14 +782,36 @@ export default {
} }
}, },
// 生成加密的 auth 参数(根据当前链接的网盘类型 // 生成加密的 auth 参数(优先使用个人配置,否则从后端随机获取捐赠账号
generateAuthParam() { async generateAuthParam() {
const panType = this.getCurrentPanType() const panType = this.getCurrentPanType()
if (!panType || !this.allAuthConfigs[panType]) { if (!panType) return ''
return ''
let config = null
// 优先使用个人配置
if (this.allAuthConfigs[panType]) {
config = this.allAuthConfigs[panType]
console.log(`[认证] 使用个人配置: ${this.getPanDisplayName(panType)}`)
} else {
// 从后端随机获取捐赠账号
try {
const response = await axios.get(`${this.baseAPI}/v2/randomAuth`, { params: { panType } })
// 解包 JsonResult 嵌套
let data = response.data
while (data && data.data !== undefined && data.code !== undefined) {
data = data.data
}
if (data && (data.token || data.username)) {
config = data
console.log(`[认证] 使用捐赠账号: ${this.getPanDisplayName(panType)}`)
}
} catch (e) {
console.log(`[认证] 无可用捐赠账号: ${this.getPanDisplayName(panType)}`)
}
} }
const config = this.allAuthConfigs[panType] if (!config) return ''
// 构建 JSON 对象 // 构建 JSON 对象
const authObj = {} const authObj = {}
@@ -685,6 +826,7 @@ export default {
if (config.ext3) authObj.ext3 = config.ext3 if (config.ext3) authObj.ext3 = config.ext3
if (config.ext4) authObj.ext4 = config.ext4 if (config.ext4) authObj.ext4 = config.ext4
if (config.ext5) authObj.ext5 = config.ext5 if (config.ext5) authObj.ext5 = config.ext5
if (config.donatedAccountToken) authObj.donatedAccountToken = config.donatedAccountToken
// AES 加密 + Base64 + URL 编码 // AES 加密 + Base64 + URL 编码
try { try {
@@ -710,9 +852,9 @@ export default {
}, },
// 更新智能直链 // 更新智能直链
updateDirectLink() { async updateDirectLink() {
if (this.link) { if (this.link) {
const authParam = this.generateAuthParam() const authParam = await this.generateAuthParam()
const authSuffix = authParam ? `&auth=${authParam}` : '' const authSuffix = authParam ? `&auth=${authParam}` : ''
this.directLink = `${this.baseAPI}/parser?url=${this.link}${this.password ? `&pwd=${this.password}` : ''}${authSuffix}` this.directLink = `${this.baseAPI}/parser?url=${this.link}${this.password ? `&pwd=${this.password}` : ''}${authSuffix}`
} }
@@ -766,8 +908,8 @@ export default {
this.errorButtonVisible = false this.errorButtonVisible = false
try { try {
this.isLoading = true this.isLoading = true
// 添加认证参数 // 添加认证参数(异步获取)
const authParam = this.generateAuthParam() const authParam = await this.generateAuthParam()
if (authParam) { if (authParam) {
params.auth = authParam params.auth = authParam
} }
@@ -1086,7 +1228,7 @@ export default {
if (this.password) params.pwd = this.password if (this.password) params.pwd = this.password
// 添加认证参数 // 添加认证参数
const authParam = this.generateAuthParam() const authParam = await this.generateAuthParam()
if (authParam) params.auth = authParam if (authParam) params.auth = authParam
const response = await axios.get(`${this.baseAPI}/v2/clientLinks`, { params }) const response = await axios.get(`${this.baseAPI}/v2/clientLinks`, { params })
@@ -1115,6 +1257,119 @@ export default {
} finally { } finally {
this.isLoading = false this.isLoading = false
} }
},
// ========== 捐赠账号相关方法 ==========
// 捐赠弹窗中网盘类型变更
onDonatePanTypeChange(panType) {
const types = this.getDonateAuthTypes()
this.donateConfig.authType = types.length > 0 ? types[0].value : 'cookie'
this.donateConfig.username = ''
this.donateConfig.password = ''
this.donateConfig.token = ''
this.donateConfig.remark = ''
},
// 获取捐赠弹窗支持的认证类型
getDonateAuthTypes() {
const pt = (this.donateConfig.panType || '').toLowerCase()
const allTypes = {
cookie: { label: 'Cookie', value: 'cookie' },
accesstoken: { label: 'AccessToken', value: 'accesstoken' },
authorization: { label: 'Authorization', value: 'authorization' },
password: { label: '用户名密码', value: 'password' },
custom: { label: '自定义', value: 'custom' }
}
switch (pt) {
case 'qk': case 'uc': return [allTypes.cookie]
case 'fj': case 'iz': return [allTypes.password]
case 'ye': return [allTypes.password, allTypes.authorization]
default: return Object.values(allTypes)
}
},
// 提交捐赠账号(调用后端 API
async submitDonateAccount() {
if (!this.donateConfig.panType) {
this.$message.warning('请选择网盘类型')
return
}
if (!this.donateConfig.token && !this.donateConfig.username) {
this.$message.warning('请填写认证信息Cookie/Token 或 用户名密码)')
return
}
this.donateSubmitting = true
try {
const payload = {
panType: this.donateConfig.panType,
authType: this.donateConfig.authType,
username: this.donateConfig.username || '',
password: this.donateConfig.password || '',
token: this.donateConfig.token || '',
remark: this.donateConfig.remark || ''
}
await axios.post(`${this.baseAPI}/v2/donateAccount`, payload)
this.$message.success(`已捐赠 ${this.getPanDisplayName(this.donateConfig.panType)} 账号,感谢您的贡献!`)
// 重置表单
this.donateConfig.username = ''
this.donateConfig.password = ''
this.donateConfig.token = ''
this.donateConfig.remark = ''
// 刷新计数
await this.loadDonateAccountCounts()
} catch (e) {
console.error('捐赠账号失败:', e)
this.$message.error('捐赠失败,请稍后重试')
} finally {
this.donateSubmitting = false
}
},
// 从后端加载捐赠账号数量统计
async loadDonateAccountCounts() {
try {
const response = await axios.get(`${this.baseAPI}/v2/donateAccountCounts`)
// 解包可能的 JsonResult 嵌套
let data = response.data
while (data && data.data !== undefined && data.code !== undefined) {
data = data.data
}
if (data && typeof data === 'object') {
// 兼容新结构: { active: {...}, inactive: {...} }
if (data.active && data.inactive) {
if (data.active.total === undefined) {
data.active.total = Object.entries(data.active)
.filter(([k, v]) => k !== 'total' && typeof v === 'number')
.reduce((s, [, v]) => s + v, 0)
}
if (data.inactive.total === undefined) {
data.inactive.total = Object.entries(data.inactive)
.filter(([k, v]) => k !== 'total' && typeof v === 'number')
.reduce((s, [, v]) => s + v, 0)
}
this.donateAccountCounts = data
} else {
// 兼容旧结构: { QK: 3, total: 4 }
const active = { ...data }
if (active.total === undefined) {
active.total = Object.entries(active)
.filter(([k, v]) => k !== 'total' && typeof v === 'number')
.reduce((s, [, v]) => s + v, 0)
}
this.donateAccountCounts = {
active,
inactive: { total: 0 }
}
}
}
} catch (e) {
console.error('加载捐赠账号统计失败:', e)
}
} }
}, },
@@ -1128,6 +1383,9 @@ export default {
// 加载认证配置 // 加载认证配置
this.loadAuthConfig() this.loadAuthConfig()
// 加载捐赠账号统计
this.loadDonateAccountCounts()
// 获取初始统计信息 // 获取初始统计信息
this.getInfo() this.getInfo()

View File

@@ -78,12 +78,17 @@ public class AuthParamCodec {
} }
try { try {
// Step 1: URL解码 // Step 1: URL解码(兼容:有些框架已自动解码,此处避免再次把 '+' 变成空格)
String urlDecoded = URLDecoder.decode(encryptedAuth, StandardCharsets.UTF_8); String normalized = encryptedAuth;
log.debug("URL解码结果: {}", urlDecoded); if (normalized.contains("%")) {
normalized = URLDecoder.decode(normalized, StandardCharsets.UTF_8);
}
// 兼容 query 参数中 '+' 被还原为空格的情况
normalized = normalized.replace(' ', '+');
log.debug("认证参数规范化结果: {}", normalized);
// Step 2: Base64解码 // Step 2: Base64解码
byte[] base64Decoded = Base64.getDecoder().decode(urlDecoded); byte[] base64Decoded = Base64.getDecoder().decode(normalized);
log.debug("Base64解码成功长度: {}", base64Decoded.length); log.debug("Base64解码成功长度: {}", base64Decoded.length);
// Step 3: AES解密 // Step 3: AES解密

View File

@@ -28,6 +28,7 @@ import io.vertx.core.Promise;
import io.vertx.core.http.HttpServerRequest; import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.http.HttpServerResponse; import io.vertx.core.http.HttpServerResponse;
import io.vertx.core.json.JsonObject; import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@@ -477,4 +478,37 @@ public class ParserApi {
ClientLinkType type = ClientLinkType.valueOf(clientType.toUpperCase()); ClientLinkType type = ClientLinkType.valueOf(clientType.toUpperCase());
return clientLinks.get(type); return clientLinks.get(type);
} }
// ========== 捐赠账号 API ==========
/**
* 捐赠网盘账号
*/
@RouteMapping(value = "/donateAccount", method = RouteMethod.POST)
public Future<JsonObject> donateAccount(RoutingContext ctx) {
JsonObject body = ctx.body().asJsonObject();
if (body == null || StringUtils.isBlank(body.getString("panType"))
|| StringUtils.isBlank(body.getString("authType"))) {
return Future.succeededFuture(JsonResult.error("panType and authType are required").toJsonObject());
}
String ip = ctx.request().remoteAddress().host();
body.put("ip", ip);
return dbService.saveDonatedAccount(body);
}
/**
* 获取各网盘捐赠账号数量
*/
@RouteMapping(value = "/donateAccountCounts", method = RouteMethod.GET)
public Future<JsonObject> getDonateAccountCounts() {
return dbService.getDonatedAccountCounts();
}
/**
* 随机获取指定网盘类型的捐赠账号(内部使用,返回加密后的 auth 参数)
*/
@RouteMapping(value = "/randomAuth", method = RouteMethod.GET)
public Future<JsonObject> getRandomAuth(String panType) {
return dbService.getRandomDonatedAccount(panType);
}
} }

View File

@@ -5,6 +5,7 @@ import cn.qaiu.lz.common.util.URLParamUtil;
import cn.qaiu.lz.web.model.AuthParam; import cn.qaiu.lz.web.model.AuthParam;
import cn.qaiu.lz.web.model.CacheLinkInfo; import cn.qaiu.lz.web.model.CacheLinkInfo;
import cn.qaiu.lz.web.service.CacheService; import cn.qaiu.lz.web.service.CacheService;
import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.vx.core.annotaions.RouteHandler; import cn.qaiu.vx.core.annotaions.RouteHandler;
import cn.qaiu.vx.core.annotaions.RouteMapping; import cn.qaiu.vx.core.annotaions.RouteMapping;
import cn.qaiu.vx.core.enums.RouteMethod; import cn.qaiu.vx.core.enums.RouteMethod;
@@ -29,6 +30,7 @@ import lombok.extern.slf4j.Slf4j;
public class ServerApi { public class ServerApi {
private final CacheService cacheService = AsyncServiceUtil.getAsyncServiceInstance(CacheService.class); private final CacheService cacheService = AsyncServiceUtil.getAsyncServiceInstance(CacheService.class);
private final DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class);
@RouteMapping(value = "/parser", method = RouteMethod.GET, order = 1) @RouteMapping(value = "/parser", method = RouteMethod.GET, order = 1)
public Future<Void> parse(HttpServerResponse response, HttpServerRequest request, RoutingContext rcx, String pwd, String auth) { public Future<Void> parse(HttpServerResponse response, HttpServerRequest request, RoutingContext rcx, String pwd, String auth) {
@@ -43,7 +45,10 @@ public class ServerApi {
response.putHeader("nfd-cache-hit", res.getCacheHit().toString()) response.putHeader("nfd-cache-hit", res.getCacheHit().toString())
.putHeader("nfd-cache-expires", res.getExpires()), .putHeader("nfd-cache-expires", res.getExpires()),
res.getDirectLink(), promise)) res.getDirectLink(), promise))
.onFailure(t -> promise.fail(t.fillInStackTrace())); .onFailure(t -> {
recordDonatedAccountFailureIfNeeded(otherParam, t);
promise.fail(t.fillInStackTrace());
});
return promise.future(); return promise.future();
} }
@@ -51,7 +56,8 @@ public class ServerApi {
public Future<CacheLinkInfo> parseJson(HttpServerRequest request, String pwd, String auth) { public Future<CacheLinkInfo> parseJson(HttpServerRequest request, String pwd, String auth) {
String url = URLParamUtil.parserParams(request); String url = URLParamUtil.parserParams(request);
JsonObject otherParam = buildOtherParam(request, auth); JsonObject otherParam = buildOtherParam(request, auth);
return cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam); return cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam)
.onFailure(t -> recordDonatedAccountFailureIfNeeded(otherParam, t));
} }
@RouteMapping(value = "/json/:type/:key", method = RouteMethod.GET) @RouteMapping(value = "/json/:type/:key", method = RouteMethod.GET)
@@ -106,10 +112,48 @@ public class ServerApi {
otherParam.put("authInfo3", authParam.getExt3()); otherParam.put("authInfo3", authParam.getExt3());
otherParam.put("authInfo4", authParam.getExt4()); otherParam.put("authInfo4", authParam.getExt4());
otherParam.put("authInfo5", authParam.getExt5()); otherParam.put("authInfo5", authParam.getExt5());
if (authParam.getDonatedAccountToken() != null && !authParam.getDonatedAccountToken().isBlank()) {
otherParam.put("donatedAccountToken", authParam.getDonatedAccountToken());
}
log.debug("已解码认证参数: authType={}", authParam.getAuthType()); log.debug("已解码认证参数: authType={}", authParam.getAuthType());
} }
} }
return otherParam; return otherParam;
} }
private void recordDonatedAccountFailureIfNeeded(JsonObject otherParam, Throwable cause) {
if (!isLikelyAuthFailure(cause)) {
return;
}
String donatedAccountToken = otherParam.getString("donatedAccountToken");
if (donatedAccountToken == null || donatedAccountToken.isBlank()) {
return;
}
dbService.recordDonatedAccountFailureByToken(donatedAccountToken)
.onFailure(e -> log.warn("记录捐赠账号失败次数失败", e));
}
private boolean isLikelyAuthFailure(Throwable cause) {
if (cause == null) {
return false;
}
String msg = cause.getMessage();
if (msg == null || msg.isBlank()) {
return false;
}
String lower = msg.toLowerCase();
return lower.contains("auth")
|| lower.contains("token")
|| lower.contains("cookie")
|| lower.contains("password")
|| lower.contains("credential")
|| lower.contains("401")
|| lower.contains("403")
|| lower.contains("unauthorized")
|| lower.contains("forbidden")
|| lower.contains("expired")
|| lower.contains("登录")
|| lower.contains("认证");
}
} }

View File

@@ -93,6 +93,11 @@ public class AuthParam implements ToJson {
*/ */
private String ext5; private String ext5;
/**
* 捐赠账号失败计数令牌(服务端签发,不可伪造)
*/
private String donatedAccountToken;
/** /**
* 从 JsonObject 构造 * 从 JsonObject 构造
*/ */
@@ -111,6 +116,7 @@ public class AuthParam implements ToJson {
this.ext3 = json.getString("ext3"); this.ext3 = json.getString("ext3");
this.ext4 = json.getString("ext4"); this.ext4 = json.getString("ext4");
this.ext5 = json.getString("ext5"); this.ext5 = json.getString("ext5");
this.donatedAccountToken = json.getString("donatedAccountToken");
} }
/** /**
@@ -129,6 +135,7 @@ public class AuthParam implements ToJson {
if (ext3 != null) json.put("ext3", ext3); if (ext3 != null) json.put("ext3", ext3);
if (ext4 != null) json.put("ext4", ext4); if (ext4 != null) json.put("ext4", ext4);
if (ext5 != null) json.put("ext5", ext5); if (ext5 != null) json.put("ext5", ext5);
if (donatedAccountToken != null) json.put("donatedAccountToken", donatedAccountToken);
return json; return json;
} }

View File

@@ -0,0 +1,55 @@
package cn.qaiu.lz.web.model;
import cn.qaiu.db.ddl.Constraint;
import cn.qaiu.db.ddl.Length;
import cn.qaiu.db.ddl.Table;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.util.Date;
/**
* 捐赠账号实体
* 用户捐赠的网盘认证信息,解析时随机选择使用
*/
@Data
@Table("donated_account")
public class DonatedAccount {
private static final long serialVersionUID = 1L;
@Constraint(autoIncrement = true, notNull = true)
private Long id;
@Length(varcharSize = 16)
@Constraint(notNull = true)
private String panType; // 网盘类型: QK, UC, FJ, IZ, YE
@Length(varcharSize = 32)
@Constraint(notNull = true)
private String authType; // 认证类型: cookie, accesstoken, authorization, password, custom
@Length(varcharSize = 128)
private String username; // 用户名
@Length(varcharSize = 128)
private String password; // 密码
@Length(varcharSize = 4096)
private String token; // Cookie/Token
@Length(varcharSize = 64)
private String remark; // 备注
@Length(varcharSize = 64)
private String ip; // 捐赠者IP
@Constraint(notNull = true, defaultValue = "true")
private Boolean enabled = true; // 是否启用
@Constraint(notNull = true, defaultValue = "0")
private Integer failCount = 0; // 失败次数,达到阈值自动禁用
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime = new Date();
}

View File

@@ -50,4 +50,30 @@ public interface DbService extends BaseAsyncService {
*/ */
Future<JsonObject> getPlaygroundParserById(Long id); Future<JsonObject> getPlaygroundParserById(Long id);
// ========== 捐赠账号相关 ==========
/**
* 保存捐赠账号
*/
Future<JsonObject> saveDonatedAccount(JsonObject account);
/**
* 获取各网盘捐赠账号数量统计
*/
Future<JsonObject> getDonatedAccountCounts();
/**
* 随机获取指定网盘类型的一个启用账号
*/
Future<JsonObject> getRandomDonatedAccount(String panType);
/**
* 签发捐赠账号失败计数令牌(服务端临时令牌)
*/
Future<String> issueDonatedAccountFailureToken(Long accountId);
/**
* 使用服务端失败计数令牌记录捐赠账号解析失败
*/
Future<Void> recordDonatedAccountFailureByToken(String failureToken);
} }

View File

@@ -2,8 +2,9 @@ package cn.qaiu.lz.web.service.impl;
import cn.qaiu.db.pool.JDBCPoolInit; import cn.qaiu.db.pool.JDBCPoolInit;
import cn.qaiu.lz.common.model.UserInfo; import cn.qaiu.lz.common.model.UserInfo;
import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.lz.web.model.StatisticsInfo; import cn.qaiu.lz.web.model.StatisticsInfo;
import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.lz.web.util.CryptoUtil;
import cn.qaiu.vx.core.annotaions.Service; import cn.qaiu.vx.core.annotaions.Service;
import cn.qaiu.vx.core.model.JsonResult; import cn.qaiu.vx.core.model.JsonResult;
import io.vertx.core.Future; import io.vertx.core.Future;
@@ -14,8 +15,13 @@ import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.Tuple; import io.vertx.sqlclient.Tuple;
import io.vertx.sqlclient.templates.SqlTemplate; import io.vertx.sqlclient.templates.SqlTemplate;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -28,6 +34,11 @@ import java.util.List;
@Slf4j @Slf4j
@Service @Service
public class DbServiceImpl implements DbService { public class DbServiceImpl implements DbService {
private static final int DONATED_ACCOUNT_DISABLE_THRESHOLD = 3;
private static final long FAILURE_TOKEN_TTL_MILLIS = 10 * 60 * 1000L;
private static final String HMAC_ALGORITHM = "HmacSHA256";
private static final String DONATED_ACCOUNT_TOKEN_SIGN_KEY_CONFIG = "donatedAccountFailureTokenSignKey";
private static final String DONATED_ACCOUNT_TOKEN_SIGN_KEY_FALLBACK = "nfd_donate_fail_token_sign_2026";
@Override @Override
public Future<JsonObject> sayOk(String data) { public Future<JsonObject> sayOk(String data) {
log.info("say ok1 -> wait..."); log.info("say ok1 -> wait...");
@@ -265,4 +276,249 @@ public class DbServiceImpl implements DbService {
return promise.future(); return promise.future();
} }
// ========== 捐赠账号相关 ==========
@Override
public Future<JsonObject> saveDonatedAccount(JsonObject account) {
JDBCPool client = JDBCPoolInit.instance().getPool();
Future<String> encryptedUsername = CryptoUtil.encrypt(account.getString("username"));
Future<String> encryptedPassword = CryptoUtil.encrypt(account.getString("password"));
Future<String> encryptedToken = CryptoUtil.encrypt(account.getString("token"));
return ensureFailCountColumn(client).compose(v ->
Future.all(encryptedUsername, encryptedPassword, encryptedToken).compose(compositeFuture -> {
String sql = """
INSERT INTO donated_account
(pan_type, auth_type, username, password, token, remark, ip, enabled, fail_count, create_time)
VALUES (?, ?, ?, ?, ?, ?, ?, true, 0, NOW())
""";
return client.preparedQuery(sql)
.execute(Tuple.of(
account.getString("panType"),
account.getString("authType"),
encryptedUsername.result(),
encryptedPassword.result(),
encryptedToken.result(),
account.getString("remark"),
account.getString("ip")
))
.map(res -> JsonResult.success("捐赠成功").toJsonObject())
.onFailure(e -> log.error("saveDonatedAccount failed", e));
}));
} }
@Override
public Future<JsonObject> getDonatedAccountCounts() {
JDBCPool client = JDBCPoolInit.instance().getPool();
String sql = "SELECT pan_type, enabled, COUNT(*) as count FROM donated_account GROUP BY pan_type, enabled";
return client.query(sql).execute().map(rows -> {
JsonObject result = new JsonObject();
JsonObject activeCounts = new JsonObject();
JsonObject inactiveCounts = new JsonObject();
int totalActive = 0;
int totalInactive = 0;
for (Row row : rows) {
String panType = row.getString("pan_type");
boolean enabled = row.getBoolean("enabled");
int count = row.getInteger("count");
if (enabled) {
activeCounts.put(panType, count);
totalActive += count;
} else {
inactiveCounts.put(panType, count);
totalInactive += count;
}
}
activeCounts.put("total", totalActive);
inactiveCounts.put("total", totalInactive);
result.put("active", activeCounts);
result.put("inactive", inactiveCounts);
return JsonResult.data(result).toJsonObject();
}).onFailure(e -> log.error("getDonatedAccountCounts failed", e));
}
@Override
public Future<JsonObject> getRandomDonatedAccount(String panType) {
JDBCPool client = JDBCPoolInit.instance().getPool();
String sql = "SELECT * FROM donated_account WHERE pan_type = ? AND enabled = true ORDER BY RAND() LIMIT 1";
return client.preparedQuery(sql)
.execute(Tuple.of(panType))
.compose(rows -> {
if (rows.size() > 0) {
Row row = rows.iterator().next();
Future<String> usernameFuture = decryptOrPlain(row.getString("username"));
Future<String> passwordFuture = decryptOrPlain(row.getString("password"));
Future<String> tokenFuture = decryptOrPlain(row.getString("token"));
Future<String> failureTokenFuture = issueDonatedAccountFailureToken(row.getLong("id"));
return Future.all(usernameFuture, passwordFuture, tokenFuture, failureTokenFuture)
.map(compositeFuture -> {
String username = usernameFuture.result();
String password = passwordFuture.result();
String token = tokenFuture.result();
// 如果解密后没有任何可用凭证,返回空对象,避免把密文当作明文认证参数下发给前端
if (StringUtils.isBlank(username) && StringUtils.isBlank(password) && StringUtils.isBlank(token)) {
log.warn("random donated account has no usable credential after decrypt, accountId={}", row.getLong("id"));
return JsonResult.data(new JsonObject()).toJsonObject();
}
JsonObject account = new JsonObject();
account.put("authType", row.getString("auth_type"));
account.put("username", username);
account.put("password", password);
account.put("token", token);
account.put("donatedAccountToken", failureTokenFuture.result());
return JsonResult.data(account).toJsonObject();
});
} else {
return Future.succeededFuture(JsonResult.data(new JsonObject()).toJsonObject());
}
})
.onFailure(e -> log.error("getRandomDonatedAccount failed", e));
}
@Override
public Future<String> issueDonatedAccountFailureToken(Long accountId) {
if (accountId == null) {
return Future.failedFuture("accountId is null");
}
try {
long issuedAt = System.currentTimeMillis();
String payload = accountId + ":" + issuedAt;
String signature = hmacSha256(payload);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes(StandardCharsets.UTF_8))
+ "."
+ Base64.getUrlEncoder().withoutPadding().encodeToString(signature.getBytes(StandardCharsets.UTF_8));
return Future.succeededFuture(token);
} catch (Exception e) {
return Future.failedFuture(e);
}
}
@Override
public Future<Void> recordDonatedAccountFailureByToken(String failureToken) {
JDBCPool client = JDBCPoolInit.instance().getPool();
Long accountId;
try {
accountId = parseAndVerifyFailureToken(failureToken);
} catch (Exception e) {
return Future.failedFuture(e);
}
String updateSql = """
UPDATE donated_account
SET fail_count = fail_count + 1,
enabled = CASE
WHEN fail_count + 1 >= ? THEN false
ELSE enabled
END
WHERE id = ?
""";
return ensureFailCountColumn(client)
.compose(v -> client.preparedQuery(updateSql)
.execute(Tuple.of(DONATED_ACCOUNT_DISABLE_THRESHOLD, accountId)))
.map(rows -> (Void) null)
.onFailure(e -> log.error("recordDonatedAccountFailureByToken failed", e));
}
private Future<Void> ensureFailCountColumn(JDBCPool client) {
Promise<Void> promise = Promise.promise();
String sql = "ALTER TABLE donated_account ADD COLUMN IF NOT EXISTS fail_count INT DEFAULT 0 NOT NULL";
client.query(sql).execute()
.onSuccess(res -> promise.complete())
.onFailure(e -> {
String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase();
if (!(msg.contains("duplicate") || msg.contains("exists") || msg.contains("already"))) {
log.warn("ensure fail_count column failed, continue without schema migration", e);
}
promise.complete();
});
return promise.future();
}
private Future<String> decryptOrPlain(String value) {
if (value == null) {
return Future.succeededFuture(null);
}
if (!isLikelyEncrypted(value)) {
return Future.succeededFuture(value);
}
return CryptoUtil.decrypt(value).recover(e -> {
// value 看起来像密文但无法解密,通常是密钥轮换/不一致导致;
// 不应回退为明文,否则会把密文误当 token/cookie 返回给调用方
log.warn("decrypt donated account field failed, fallback to null to avoid ciphertext leakage", e);
return Future.succeededFuture((String) null);
});
}
private boolean isLikelyEncrypted(String value) {
try {
byte[] decoded = Base64.getDecoder().decode(value);
return decoded.length > 16;
} catch (Exception e) {
return false;
}
}
private Long parseAndVerifyFailureToken(String token) throws Exception {
if (token == null || token.isBlank() || !token.contains(".")) {
throw new IllegalArgumentException("invalid donated account token");
}
String[] parts = token.split("\\.", 2);
String payload = new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8);
String signature = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
String expected = hmacSha256(payload);
if (!expected.equals(signature)) {
throw new IllegalArgumentException("donated account token signature invalid");
}
String[] payloadParts = payload.split(":", 2);
if (payloadParts.length != 2) {
throw new IllegalArgumentException("invalid donated account token payload");
}
Long accountId = Long.parseLong(payloadParts[0]);
long issuedAt = Long.parseLong(payloadParts[1]);
if (System.currentTimeMillis() - issuedAt > FAILURE_TOKEN_TTL_MILLIS) {
throw new IllegalArgumentException("donated account token expired");
}
return accountId;
}
private String hmacSha256(String payload) throws Exception {
String secret = getDonatedAccountFailureTokenSignKey();
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM));
byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(digest);
}
private String getDonatedAccountFailureTokenSignKey() {
try {
String configKey = cn.qaiu.vx.core.util.SharedDataUtil
.getJsonStringForServerConfig(DONATED_ACCOUNT_TOKEN_SIGN_KEY_CONFIG);
if (StringUtils.isNotBlank(configKey)) {
return configKey;
}
} catch (Exception e) {
log.debug("读取捐赠账号失败计数签名密钥失败,使用默认值: {}", e.getMessage());
}
return DONATED_ACCOUNT_TOKEN_SIGN_KEY_FALLBACK;
}
}

View File

@@ -0,0 +1,7 @@
package cn.qaiu.lz.web.util;
public class CryptoException extends RuntimeException {
public CryptoException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,105 @@
package cn.qaiu.lz.web.util;
import cn.qaiu.vx.core.util.ConfigUtil;
import cn.qaiu.vx.core.util.VertxHolder;
import io.vertx.core.Future;
import io.vertx.core.Vertx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
public class CryptoUtil {
private static final Logger logger = LoggerFactory.getLogger(CryptoUtil.class);
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int GCM_IV_LENGTH = 12; // 96 bits
private static final int GCM_TAG_LENGTH = 16; // 128 bits
private static Future<SecretKeySpec> secretKeyFuture;
static {
Vertx vertx = VertxHolder.getVertxInstance();
if (vertx != null) {
secretKeyFuture = ConfigUtil.readYamlConfig("secret", vertx)
.map(config -> {
String key = config.getJsonObject("encrypt").getString("key");
if (key != null) {
key = key.trim();
}
byte[] keyBytes = key == null ? null : key.getBytes(StandardCharsets.UTF_8);
if (keyBytes == null || keyBytes.length != 32) {
int currentLen = keyBytes == null ? 0 : keyBytes.length;
throw new IllegalArgumentException("Invalid AES key length in secret.yml. Key must be 32 bytes. current=" + currentLen);
}
return new SecretKeySpec(keyBytes, "AES");
})
.onFailure(err -> logger.error("Failed to load encryption key from secret.yml", err));
} else {
logger.error("Vertx instance is not available for CryptoUtil initialization.");
secretKeyFuture = Future.failedFuture("Vertx instance not available.");
}
}
public static Future<String> encrypt(String strToEncrypt) {
if (strToEncrypt == null) {
return Future.succeededFuture(null);
}
return secretKeyFuture.compose(secretKey -> {
try {
byte[] iv = new byte[GCM_IV_LENGTH];
SecureRandom random = new SecureRandom();
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmParameterSpec);
byte[] cipherText = cipher.doFinal(strToEncrypt.getBytes(StandardCharsets.UTF_8));
// Prepend IV to ciphertext
ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + cipherText.length);
byteBuffer.put(iv);
byteBuffer.put(cipherText);
return Future.succeededFuture(Base64.getEncoder().encodeToString(byteBuffer.array()));
} catch (Exception e) {
return Future.failedFuture(new CryptoException("Encryption failed", e));
}
});
}
public static Future<String> decrypt(String strToDecrypt) {
if (strToDecrypt == null) {
return Future.succeededFuture(null);
}
return secretKeyFuture.compose(secretKey -> {
try {
byte[] decodedBytes = Base64.getDecoder().decode(strToDecrypt);
// Extract IV from the beginning of the decoded bytes
ByteBuffer byteBuffer = ByteBuffer.wrap(decodedBytes);
byte[] iv = new byte[GCM_IV_LENGTH];
byteBuffer.get(iv);
byte[] cipherText = new byte[byteBuffer.remaining()];
byteBuffer.get(cipherText);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmParameterSpec);
byte[] decryptedText = cipher.doFinal(cipherText);
return Future.succeededFuture(new String(decryptedText, StandardCharsets.UTF_8));
} catch (Exception e) {
return Future.failedFuture(new CryptoException("Decryption failed", e));
}
});
}
}