mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-08-26 11:32:02 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3494271150 | |||
| 675d930f86 | |||
| 5e20ed5050 | |||
| 91f4fcaa76 | |||
| 0754fe20f1 | |||
| 034fd20d3b |
@@ -6,8 +6,10 @@ QQ交流群:1017480890
|
||||
<a href="https://vertx-china.github.io"><img src="https://img.shields.io/badge/vert.x-4.5.27-blue?style=flat"></a>
|
||||
<a href="https://raw.githubusercontent.com/qaiu/netdisk-fast-download/master/LICENSE"><img src="https://img.shields.io/github/license/qaiu/netdisk-fast-download?style=flat"></a>
|
||||
<a href="https://github.com/qaiu/netdisk-fast-download/releases/"><img src="https://img.shields.io/github/v/release/qaiu/netdisk-fast-download?style=flat"></a>
|
||||
<a href="https://github.com/QAIU/netdisk-fast-download">
|
||||
<img src="https://img.shields.io/github/stars/QAIU/netdisk-fast-download?style=flat&logo=github" alt="GitHub Stars">
|
||||
</a>
|
||||
<a href="https://atomgit.com/QAIU/netdisk-fast-download"><img src="https://atomgit.com/QAIU/netdisk-fast-download/star/badge.svg" alt="AtomGit"></a>
|
||||
<a href="https://oosmetrics.com/repo/qaiu/netdisk-fast-download"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/826aa27a-6e59-4de5-b7fa-cd189f484035.svg"></a>
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/12101" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12101" alt="qaiu%2Fnetdisk-fast-download | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
@@ -67,7 +69,7 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
|
||||
- [移动云云空间-ec](https://www.ecpan.cn/web)
|
||||
- [小飞机网盘-fj](https://www.feijipan.com/)
|
||||
- [亿方云-fc](https://www.fangcloud.com/)
|
||||
- [123云盘-ye](https://www.123pan.com/)
|
||||
- [123云盘-ye](https://www.123pan.com/) ⚠️仅建议本地部署使用,需登录认证,公共/云端服务器风控严格建议自行部署(Windows 可直接用 run.bat 一键运行)
|
||||
- ~[115网盘(失效)-p115](https://115.com/)~
|
||||
- [文叔叔-ws](https://www.wenshushu.cn/)
|
||||
- [联想乐云-le](https://lecloud.lenovo.com/)
|
||||
@@ -174,6 +176,7 @@ GET /json/getFileList?url={分享链接}&pwd={密码}
|
||||
| UC网盘(UC) | **必须** | 必须配置 Cookie 才能解析 |
|
||||
| 小飞机网盘(FJ) | 可选 | 大文件(>100MB)需要认证 |
|
||||
| 蓝奏优享(IZ) | 可选 | 大文件需要认证 |
|
||||
| 123网盘(YE) | 可选 | 需要下载大文件/需要登录的分享时才需要认证,支持账号密码或 token/authorization |
|
||||
|
||||
**使用示例**:
|
||||
```
|
||||
@@ -194,6 +197,38 @@ GET /parser?url={分享链接}&pwd={密码}&auth={加密后的认证参数}
|
||||
- 目的:防止客户端伪造失败计数请求
|
||||
- 建议:使用高强度随机字符串,且不要与 `authEncryptKey` 相同
|
||||
|
||||
#### `auth` 临时认证参数 与 `auths` 静态配置认证 的区别
|
||||
|
||||
本项目存在两种互相独立的认证配置方式,作用范围不同,不要混淆:
|
||||
|
||||
| 方式 | 配置位置 | 生效范围 | 适用场景 |
|
||||
|------|---------|---------|---------|
|
||||
| `auth` 临时认证参数 | 请求 URL 上的 `auth` 查询参数 | **仅当次请求**,优先级高于 app-dev.yml 中的静态配置 | 调用方按用户临时提供的账号/Cookie/token 解析,不同请求可携带不同认证信息 |
|
||||
| `auths` 静态配置认证 | `app-dev.yml` 的 `auths.<网盘标识>` 节点 | **服务端长期生效**,所有未携带 `auth` 参数的请求都会复用 | 部署方自己长期配置一份账号,供所有请求默认使用 |
|
||||
|
||||
以 123网盘(`ye`)为例,`app-dev.yml` 中支持以下几种写法(三选一即可):
|
||||
|
||||
```yaml
|
||||
auths:
|
||||
ye:
|
||||
username: 你的123网盘账号
|
||||
password: 你的123网盘密码
|
||||
```
|
||||
```yaml
|
||||
auths:
|
||||
ye:
|
||||
token: 已登录后获取的 Authorization/AccessToken
|
||||
```
|
||||
```yaml
|
||||
auths:
|
||||
ye:
|
||||
authorization: 已登录后获取的 Authorization/AccessToken # 与 token 等价,二选一
|
||||
```
|
||||
|
||||
> ⚠️ 注意:YAML 中 key 后面不写值(如 `authorization:` 空着)等同于没配置,不会生效,必须填入真实的账号密码或 token 内容。
|
||||
|
||||
如果只是临时调用一次,不想改动服务端配置,也可以用上面提到的 `auth` 参数临时传递(`authType` 可选 `password`/`accesstoken`/`authorization`),无需重启服务,仅本次请求生效。
|
||||
|
||||
### 特殊说明
|
||||
|
||||
- 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值
|
||||
|
||||
@@ -79,13 +79,48 @@ public class Ye2Tool extends PanBase {
|
||||
accountId = auths.get("_configId");
|
||||
} else if (auths.contains("username")) {
|
||||
accountId = auths.get("username");
|
||||
} else if (auths.contains("token")) {
|
||||
String token = auths.get("token");
|
||||
accountId = token.substring(0, Math.min(16, token.length()));
|
||||
} else {
|
||||
String token = resolveProvidedToken(auths);
|
||||
if (StringUtils.isNotEmpty(token)) {
|
||||
accountId = token.substring(0, Math.min(16, token.length()));
|
||||
}
|
||||
}
|
||||
return accountId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从配置/临时认证参数中取出可直接使用的 Bearer token。
|
||||
* 兼容两种 key:
|
||||
* - token:URLParamUtil 处理 auth= 临时参数(authType=accesstoken/authorization)时写入的字段名
|
||||
* - authorization:app-dev.yml 静态配置中更符合直觉的写法(auths.ye.authorization: xxx)
|
||||
*/
|
||||
private String resolveProvidedToken(MultiMap auths) {
|
||||
if (auths == null) {
|
||||
return null;
|
||||
}
|
||||
String token = auths.get("token");
|
||||
if (StringUtils.isEmpty(token)) {
|
||||
token = auths.get("authorization");
|
||||
}
|
||||
return stripBearerPrefix(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户/捐赠账号提供的 token 经常是从浏览器开发者工具里连同 "Bearer " 前缀一起复制过来的,
|
||||
* 如果不做清理,后续 header.set("Authorization", "Bearer " + token) 会拼出
|
||||
* "Bearer Bearer xxx",导致123网盘接口报错 tokenstring should not contain 'bearer '。
|
||||
*/
|
||||
private String stripBearerPrefix(String token) {
|
||||
if (StringUtils.isBlank(token)) {
|
||||
return token;
|
||||
}
|
||||
String trimmed = token.trim();
|
||||
if (trimmed.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
trimmed = trimmed.substring(7).trim();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private boolean isTokenExpired() {
|
||||
return TokenCache.isExpired(cacheKey);
|
||||
}
|
||||
@@ -189,15 +224,14 @@ public class Ye2Tool extends PanBase {
|
||||
private Future<String> resolveTokenFuture() {
|
||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
if (auths != null) {
|
||||
String raw = null;
|
||||
if (auths.contains("token")) {
|
||||
raw = auths.get("token");
|
||||
} else if (auths.contains("Authorization")) {
|
||||
raw = auths.get("Authorization");
|
||||
}
|
||||
String providedToken = normalizeBearerToken(raw);
|
||||
if (StringUtils.isNotEmpty(providedToken)) {
|
||||
log.info("使用配置token,前8位: {}...", providedToken.substring(0, Math.min(8, providedToken.length())));
|
||||
// 当同时提供了用户名+密码时,优先走真实登录流程。
|
||||
// 注:前端/auth参数中 authType=password|username_password 时,URLParamUtil 出于兼容旧解析器的目的
|
||||
// 会把 authToken(用户名) 同时写入 token 字段,如果这里无条件信任 token,会把用户名当成
|
||||
// Bearer token 使用,导致真实的账号密码登录被跳过。
|
||||
boolean hasCredential = StringUtils.isNotEmpty(auths.get("username"))
|
||||
&& StringUtils.isNotEmpty(auths.get("password"));
|
||||
String providedToken = resolveProvidedToken(auths);
|
||||
if (!hasCredential && StringUtils.isNotEmpty(providedToken)) {
|
||||
TokenCache.putToken(cacheKey, providedToken);
|
||||
return Future.succeededFuture(providedToken);
|
||||
}
|
||||
@@ -210,17 +244,6 @@ public class Ye2Tool extends PanBase {
|
||||
return Future.succeededFuture(cached);
|
||||
}
|
||||
|
||||
private String normalizeBearerToken(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
String token = raw.trim();
|
||||
if (token.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
token = token.substring(7).trim();
|
||||
}
|
||||
return StringUtils.isNotEmpty(token) ? token : null;
|
||||
}
|
||||
|
||||
private Future<String> loginAndGetToken() {
|
||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
if (auths == null) {
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
package cn.qaiu.parser.impl;
|
||||
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import io.vertx.core.MultiMap;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Ye2Tool token 解析验证测试
|
||||
* 验证 Authorization 字段和 ******
|
||||
*/
|
||||
public class Ye2ToolTokenValidationTest {
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.out.println("========================================");
|
||||
System.out.println(" Ye2Tool Token 解析验证测试");
|
||||
System.out.println("========================================\n");
|
||||
|
||||
testNormalizeBearerToken();
|
||||
testYe2ToolWithTokenField();
|
||||
testYe2ToolWithAuthorizationField();
|
||||
testYe2ToolWithoutAuth();
|
||||
|
||||
System.out.println("\n========================================");
|
||||
System.out.println(" 所有验证通过! ✓");
|
||||
System.out.println("========================================");
|
||||
}
|
||||
|
||||
/** 独立验证 normalizeBearerToken 逻辑(内联实现与 Ye2Tool 保持一致)*/
|
||||
private static String normalizeBearerToken(String raw) {
|
||||
if (raw == null || raw.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String token = raw.trim();
|
||||
if (token.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
token = token.substring(7).trim();
|
||||
}
|
||||
return token.isEmpty() ? null : token;
|
||||
}
|
||||
|
||||
private static void testNormalizeBearerToken() {
|
||||
System.out.println("=== 验证 normalizeBearerToken 逻辑 ===");
|
||||
|
||||
assertNormalize("testtoken123abc", "testtoken123abc", "纯 token 不变");
|
||||
assertNormalize("Bearer testtoken123abc", "testtoken123abc", "******");
|
||||
assertNormalize("bearer testtoken123abc", "testtoken123abc", "小写 bearer 前缀被去掉");
|
||||
assertNormalize("BEARER testtoken123abc", "testtoken123abc", "大写 BEARER 前缀被去掉");
|
||||
assertNormalize(" Bearer testtoken123abc ", "testtoken123abc", "前后空格被 trim");
|
||||
assertNormalizeNull(null, "null 输入返回 null");
|
||||
assertNormalizeNull("", "空字符串返回 null");
|
||||
assertNormalizeNull(" ", "纯空格返回 null");
|
||||
|
||||
System.out.println();
|
||||
}
|
||||
|
||||
private static void assertNormalize(String input, String expected, String desc) {
|
||||
String actual = normalizeBearerToken(input);
|
||||
if (!expected.equals(actual)) {
|
||||
throw new AssertionError("FAIL [" + desc + "]: expected='" + expected + "', actual='" + actual + "'");
|
||||
}
|
||||
System.out.println("✓ " + desc);
|
||||
}
|
||||
|
||||
private static void assertNormalizeNull(String input, String desc) {
|
||||
String actual = normalizeBearerToken(input);
|
||||
if (actual != null) {
|
||||
throw new AssertionError("FAIL [" + desc + "]: expected null, actual='" + actual + "'");
|
||||
}
|
||||
System.out.println("✓ " + desc);
|
||||
}
|
||||
|
||||
private static void testYe2ToolWithTokenField() {
|
||||
System.out.println("=== 测试 Ye2Tool(token 字段)===");
|
||||
try {
|
||||
MultiMap auths = MultiMap.caseInsensitiveMultiMap();
|
||||
auths.set("token", "testtoken123abc");
|
||||
|
||||
Map<String, Object> otherParam = new HashMap<>();
|
||||
otherParam.put("auths", auths);
|
||||
|
||||
ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder()
|
||||
.type("YE")
|
||||
.panName("123网盘")
|
||||
.shareKey("test_key_token")
|
||||
.shareUrl("https://www.123pan.com/s/test123")
|
||||
.build();
|
||||
shareLinkInfo.setOtherParam(otherParam);
|
||||
|
||||
Ye2Tool ye2Tool = new Ye2Tool(shareLinkInfo);
|
||||
System.out.println("✓ Ye2Tool 实例创建成功(token 字段)\n");
|
||||
} catch (Exception e) {
|
||||
System.err.println("✗ Ye2Tool(token 字段)测试失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private static void testYe2ToolWithAuthorizationField() {
|
||||
System.out.println("=== 测试 Ye2Tool(Authorization 字段,带 Bearer)===");
|
||||
try {
|
||||
MultiMap auths = MultiMap.caseInsensitiveMultiMap();
|
||||
auths.set("Authorization", "Bearer testtoken123abc");
|
||||
|
||||
Map<String, Object> otherParam = new HashMap<>();
|
||||
otherParam.put("auths", auths);
|
||||
|
||||
ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder()
|
||||
.type("YE")
|
||||
.panName("123网盘")
|
||||
.shareKey("test_key_auth")
|
||||
.shareUrl("https://www.123pan.com/s/test456")
|
||||
.build();
|
||||
shareLinkInfo.setOtherParam(otherParam);
|
||||
|
||||
Ye2Tool ye2Tool = new Ye2Tool(shareLinkInfo);
|
||||
System.out.println("✓ Ye2Tool 实例创建成功(Authorization 字段)\n");
|
||||
} catch (Exception e) {
|
||||
System.err.println("✗ Ye2Tool(Authorization 字段)测试失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private static void testYe2ToolWithoutAuth() {
|
||||
System.out.println("=== 测试 Ye2Tool(无认证配置)===");
|
||||
try {
|
||||
ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder()
|
||||
.type("YE")
|
||||
.panName("123网盘")
|
||||
.shareKey("test_key_noauth")
|
||||
.shareUrl("https://www.123pan.com/s/test789")
|
||||
.build();
|
||||
|
||||
Ye2Tool ye2Tool = new Ye2Tool(shareLinkInfo);
|
||||
System.out.println("✓ Ye2Tool 实例创建成功(无认证)\n");
|
||||
} catch (Exception e) {
|
||||
System.err.println("✗ Ye2Tool(无认证)测试失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<revision>0.3.4</revision>
|
||||
<revision>0.3.8</revision>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nfd-web",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
|
||||
@@ -326,6 +326,10 @@
|
||||
<span>蓝奏优享 (IZ)</span>
|
||||
<el-tag size="small" type="warning" style="margin-left: 8px">大文件</el-tag>
|
||||
</el-option>
|
||||
<el-option label="123云盘 (YE)" value="YE">
|
||||
<span>123云盘 (YE)</span>
|
||||
<el-tag size="small" type="warning" style="margin-left: 8px">部分分享需登录</el-tag>
|
||||
</el-option>
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -767,7 +771,8 @@ export default {
|
||||
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('ilanzou.com') || url.includes('lanzouv.com')) return 'IZ'
|
||||
if (url.includes('123pan.com') || url.includes('123684.com') || url.includes('123865.com')) return 'YE'
|
||||
// 123网盘域名较多(如 123pan.com/123pan.cn/share.123pan.cn/123684.com/123865.com 等数字域名),使用规则匹配代替枚举
|
||||
if (/123\d{3}\.com|123panpay\.com|123pan\.(?:com|cn)/.test(url)) return 'YE'
|
||||
return ''
|
||||
},
|
||||
|
||||
@@ -789,7 +794,8 @@ export default {
|
||||
'QK': '夸克网盘必须配置 Cookie 才能解析和下载(登录后从浏览器开发者工具获取)',
|
||||
'UC': 'UC网盘必须配置 Cookie 才能解析和下载(登录后从浏览器开发者工具获取)',
|
||||
'FJ': '小飞机网盘大文件(>100MB)需要配置认证信息',
|
||||
'IZ': '蓝奏优享大文件需要配置认证信息'
|
||||
'IZ': '蓝奏优享大文件需要配置认证信息',
|
||||
'YE': '123云盘部分分享(需要登录才能查看/下载)需要配置账号密码或 Authorization Token'
|
||||
}
|
||||
return hints[this.authConfig.panType] || '请选择网盘类型后配置认证信息'
|
||||
},
|
||||
@@ -1230,10 +1236,11 @@ export default {
|
||||
duration: 5000,
|
||||
showClose: true
|
||||
})
|
||||
} else if (panType === 'fj' || panType === 'lz' || panType === 'iz' || panType === 'le') {
|
||||
// 小飞机、蓝奏、优享、联想乐云:提示大文件需要认证
|
||||
} else if (panType === 'fj' || panType === 'lz' || panType === 'iz' || panType === 'le' || panType === 'ye') {
|
||||
// 小飞机、蓝奏、优享、联想乐云、123云盘:提示大文件/需登录分享需要认证
|
||||
const hasAuth = this.allAuthConfigs[panType]?.cookie ||
|
||||
this.allAuthConfigs[panType]?.username ||
|
||||
this.allAuthConfigs[panType]?.token ||
|
||||
(this.donateAccountCounts.active[panType.toUpperCase()] || 0) > 0
|
||||
if (!hasAuth) {
|
||||
this.$message.info({
|
||||
@@ -1612,12 +1619,14 @@ export default {
|
||||
|
||||
this.donateSubmitting = true
|
||||
try {
|
||||
// 只提交当前认证方式实际用到的字段,避免切换认证类型后遗留的用户名/密码脏数据被一起提交
|
||||
const isPasswordAuth = this.donateConfig.authType === 'password'
|
||||
const payload = {
|
||||
panType: this.donateConfig.panType,
|
||||
authType: this.donateConfig.authType,
|
||||
username: this.donateConfig.username || '',
|
||||
password: this.donateConfig.password || '',
|
||||
token: this.donateConfig.token || '',
|
||||
username: isPasswordAuth ? (this.donateConfig.username || '') : '',
|
||||
password: isPasswordAuth ? (this.donateConfig.password || '') : '',
|
||||
token: isPasswordAuth ? '' : (this.donateConfig.token || ''),
|
||||
remark: this.donateConfig.remark || ''
|
||||
}
|
||||
await axios.post(`${this.baseAPI}/v2/donateAccount`, payload)
|
||||
|
||||
@@ -100,21 +100,27 @@ public class URLParamUtil {
|
||||
}
|
||||
}
|
||||
if (localMap.containsKey(ConfigConstant.AUTHS)) {
|
||||
JsonObject auths = (JsonObject) localMap.get(ConfigConstant.AUTHS);
|
||||
if (auths.containsKey(type)) {
|
||||
// 需要处理引号
|
||||
MultiMap entries = MultiMap.caseInsensitiveMultiMap();
|
||||
JsonObject jsonObject = auths.getJsonObject(type);
|
||||
if (jsonObject != null) {
|
||||
jsonObject.forEach(entity -> {
|
||||
if (entity == null || entity.getValue() == null) {
|
||||
return;
|
||||
}
|
||||
entries.set(entity.getKey(), entity.getValue().toString());
|
||||
});
|
||||
}
|
||||
// 如果本次请求已经通过 auth 临时参数(个人配置/捐赠账号)设置过认证信息,
|
||||
// 则不要再用后台 app-dev.yml 的静态配置覆盖,否则临时认证会被静默清空/替换为空配置。
|
||||
boolean tempAuthAdded = Boolean.TRUE.equals(
|
||||
parserCreate.getShareLinkInfo().getOtherParam().get("__TEMP_AUTH_ADDED"));
|
||||
if (!tempAuthAdded) {
|
||||
JsonObject auths = (JsonObject) localMap.get(ConfigConstant.AUTHS);
|
||||
if (auths.containsKey(type)) {
|
||||
// 需要处理引号
|
||||
MultiMap entries = MultiMap.caseInsensitiveMultiMap();
|
||||
JsonObject jsonObject = auths.getJsonObject(type);
|
||||
if (jsonObject != null) {
|
||||
jsonObject.forEach(entity -> {
|
||||
if (entity == null || entity.getValue() == null) {
|
||||
return;
|
||||
}
|
||||
entries.set(entity.getKey(), entity.getValue().toString());
|
||||
});
|
||||
}
|
||||
|
||||
parserCreate.getShareLinkInfo().getOtherParam().put(ConfigConstant.AUTHS, entries);
|
||||
parserCreate.getShareLinkInfo().getOtherParam().put(ConfigConstant.AUTHS, entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,15 +8,22 @@ import cn.qaiu.lz.common.cache.CacheTotalField;
|
||||
import cn.qaiu.lz.common.util.URLParamUtil;
|
||||
import cn.qaiu.lz.web.model.CacheLinkInfo;
|
||||
import cn.qaiu.lz.web.service.CacheService;
|
||||
import cn.qaiu.lz.web.service.DbService;
|
||||
import cn.qaiu.parser.IPanTool;
|
||||
import cn.qaiu.parser.ParserCreate;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGeneratorFactory;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.vx.core.annotaions.Service;
|
||||
import cn.qaiu.vx.core.util.AsyncServiceUtil;
|
||||
import cn.qaiu.vx.core.util.ConfigConstant;
|
||||
import cn.qaiu.vx.core.util.VertxHolder;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.MultiMap;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.core.shareddata.LocalMap;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.time.DateFormatUtils;
|
||||
|
||||
import java.util.Date;
|
||||
@@ -28,8 +35,11 @@ import java.util.Map;
|
||||
public class CacheServiceImpl implements CacheService {
|
||||
|
||||
private static final String SKIP_CLIENT_LINKS = "_skipClientLinks";
|
||||
private static final String TEMP_AUTH_ADDED = "__TEMP_AUTH_ADDED";
|
||||
private static final String DONATED_ACCOUNT_TOKEN = "__AUTO_DONATED_ACCOUNT_TOKEN";
|
||||
|
||||
private final CacheManager cacheManager = new CacheManager();
|
||||
private final DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class);
|
||||
|
||||
static {
|
||||
// 服务类加载时注册缓存定时清理任务
|
||||
@@ -71,7 +81,9 @@ public class CacheServiceImpl implements CacheService {
|
||||
promise.fail(cause);
|
||||
return;
|
||||
}
|
||||
IPanTool.closeAfter(tool, tool::parse).onSuccess(redirectUrl -> {
|
||||
IPanTool.closeAfter(tool, tool::parse).onFailure(err -> {
|
||||
recordAutoDonatedFailureIfNeeded(shareLinkInfo, err);
|
||||
}).onSuccess(redirectUrl -> {
|
||||
// 使用 effectiveCacheDuration
|
||||
long expires = System.currentTimeMillis() + effectiveCacheDuration * 60 * 1000L;
|
||||
result.setDirectLink(redirectUrl);
|
||||
@@ -141,6 +153,124 @@ public class CacheServiceImpl implements CacheService {
|
||||
return DateFormatUtils.format(new Date(ts), "yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
|
||||
/**
|
||||
* 当本次请求没有携带临时认证参数(auth=xxx),且该网盘类型在 app-dev.yml 中也没有配置可用的
|
||||
* 静态账号密码/token 时,自动从捐赠账号池中随机挑选一个可用账号使用,
|
||||
* 这样捐赠者捐赠账号后,普通请求(不带 auth 参数)也能直接受益,无需前端额外拼接参数。
|
||||
*/
|
||||
private Future<Void> applyDonatedAccountFallback(ParserCreate parserCreate) {
|
||||
ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo();
|
||||
Map<String, Object> otherParam = shareLinkInfo.getOtherParam();
|
||||
if (Boolean.TRUE.equals(otherParam.get(TEMP_AUTH_ADDED))) {
|
||||
// 请求已经携带了个人配置/捐赠账号的临时认证参数,无需再自动回退
|
||||
return Future.succeededFuture();
|
||||
}
|
||||
String type = shareLinkInfo.getType();
|
||||
if (StringUtils.isBlank(type) || hasUsableStaticAuthConfig(type)) {
|
||||
return Future.succeededFuture();
|
||||
}
|
||||
return dbService.getRandomDonatedAccount(type.toUpperCase())
|
||||
.compose(res -> {
|
||||
if (!Integer.valueOf(200).equals(res.getInteger("code"))) {
|
||||
return Future.succeededFuture();
|
||||
}
|
||||
JsonObject data = res.getJsonObject("data");
|
||||
if (data == null || data.isEmpty()) {
|
||||
return Future.succeededFuture();
|
||||
}
|
||||
String username = data.getString("username");
|
||||
String password = data.getString("password");
|
||||
String token = data.getString("token");
|
||||
if (StringUtils.isBlank(username) && StringUtils.isBlank(password) && StringUtils.isBlank(token)) {
|
||||
return Future.succeededFuture();
|
||||
}
|
||||
MultiMap tempAuth = MultiMap.caseInsensitiveMultiMap();
|
||||
if (StringUtils.isNotBlank(username)) {
|
||||
tempAuth.set("username", username);
|
||||
}
|
||||
if (StringUtils.isNotBlank(password)) {
|
||||
tempAuth.set("password", password);
|
||||
}
|
||||
if (StringUtils.isNotBlank(token)) {
|
||||
tempAuth.set("token", token);
|
||||
}
|
||||
otherParam.put(ConfigConstant.AUTHS, tempAuth);
|
||||
otherParam.put(TEMP_AUTH_ADDED, true);
|
||||
String donatedAccountToken = data.getString("donatedAccountToken");
|
||||
if (StringUtils.isNotBlank(donatedAccountToken)) {
|
||||
otherParam.put(DONATED_ACCOUNT_TOKEN, donatedAccountToken);
|
||||
}
|
||||
log.debug("已自动应用捐赠账号: type={}", type);
|
||||
return Future.<Void>succeededFuture();
|
||||
})
|
||||
.recover(err -> {
|
||||
log.warn("自动获取捐赠账号失败: type={}", type, err);
|
||||
return Future.succeededFuture();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 app-dev.yml 中该网盘类型是否已配置了可用的静态认证信息(非空的 username/password/token/authorization 等)。
|
||||
* 如果已配置,则不需要再自动回退到捐赠账号池。
|
||||
*/
|
||||
private boolean hasUsableStaticAuthConfig(String type) {
|
||||
LocalMap<Object, Object> localMap = VertxHolder.getVertxInstance().sharedData()
|
||||
.getLocalMap(ConfigConstant.LOCAL);
|
||||
if (!localMap.containsKey(ConfigConstant.AUTHS)) {
|
||||
return false;
|
||||
}
|
||||
JsonObject auths = (JsonObject) localMap.get(ConfigConstant.AUTHS);
|
||||
JsonObject cfg = auths.getJsonObject(type);
|
||||
if (cfg == null) {
|
||||
return false;
|
||||
}
|
||||
for (String key : cfg.fieldNames()) {
|
||||
Object value = cfg.getValue(key);
|
||||
if (value != null && StringUtils.isNotBlank(value.toString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动回退使用的捐赠账号解析失败时,记录一次失败次数(达到阈值后台账号会被自动禁用)。
|
||||
*/
|
||||
private void recordAutoDonatedFailureIfNeeded(ShareLinkInfo shareLinkInfo, Throwable cause) {
|
||||
Object tokenObj = shareLinkInfo.getOtherParam().get(DONATED_ACCOUNT_TOKEN);
|
||||
if (!(tokenObj instanceof String) || StringUtils.isBlank((String) tokenObj)) {
|
||||
return;
|
||||
}
|
||||
if (!isLikelyAuthFailure(cause)) {
|
||||
return;
|
||||
}
|
||||
dbService.recordDonatedAccountFailureByToken((String) tokenObj)
|
||||
.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("认证");
|
||||
}
|
||||
|
||||
private boolean shouldGenerateClientLinks(ShareLinkInfo shareLinkInfo) {
|
||||
if (shareLinkInfo == null || shareLinkInfo.getOtherParam() == null) {
|
||||
return true;
|
||||
@@ -279,7 +409,10 @@ public class CacheServiceImpl implements CacheService {
|
||||
return Future.failedFuture(e);
|
||||
}
|
||||
parserCreate.getShareLinkInfo().getOtherParam().putAll(otherParam.getMap());
|
||||
return getAndSaveCachedShareLink(parserCreate);
|
||||
|
||||
ParserCreate finalParserCreate = parserCreate;
|
||||
return applyDonatedAccountFallback(finalParserCreate)
|
||||
.compose(v -> getAndSaveCachedShareLink(finalParserCreate));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -305,7 +438,9 @@ public class CacheServiceImpl implements CacheService {
|
||||
otherParam.getString("authInfo4"),
|
||||
otherParam.getString("authInfo5"));
|
||||
}
|
||||
|
||||
return getAndSaveCachedShareLink(parserCreate);
|
||||
|
||||
ParserCreate finalParserCreate = parserCreate;
|
||||
return applyDonatedAccountFallback(finalParserCreate)
|
||||
.compose(v -> getAndSaveCachedShareLink(finalParserCreate));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,9 +325,16 @@ public class DbServiceImpl implements DbService {
|
||||
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"));
|
||||
// 只保留当前认证方式实际用到的字段,避免调用方切换认证类型后遗留的用户名/密码脏数据
|
||||
// 被一并存入库中,导致后续解析时被误当作真实凭证使用(例如把废弃的用户名当手机号登录)。
|
||||
boolean isPasswordAuth = "password".equalsIgnoreCase(account.getString("authType"));
|
||||
String usernameToStore = isPasswordAuth ? account.getString("username") : null;
|
||||
String passwordToStore = isPasswordAuth ? account.getString("password") : null;
|
||||
String tokenToStore = isPasswordAuth ? null : account.getString("token");
|
||||
|
||||
Future<String> encryptedUsername = CryptoUtil.encrypt(usernameToStore);
|
||||
Future<String> encryptedPassword = CryptoUtil.encrypt(passwordToStore);
|
||||
Future<String> encryptedToken = CryptoUtil.encrypt(tokenToStore);
|
||||
|
||||
return ensureFailCountColumn(client).compose(v ->
|
||||
Future.all(encryptedUsername, encryptedPassword, encryptedToken).compose(compositeFuture -> {
|
||||
@@ -412,6 +419,14 @@ public class DbServiceImpl implements DbService {
|
||||
String password = passwordFuture.result();
|
||||
String token = tokenFuture.result();
|
||||
|
||||
// 历史脏数据兜底:非 password 认证类型的账号不应该带用户名/密码
|
||||
// (例如切换认证类型前遗留的表单数据),否则会被解析器误当作真实账号密码去登录。
|
||||
boolean isPasswordAuth = "password".equalsIgnoreCase(row.getString("auth_type"));
|
||||
if (!isPasswordAuth) {
|
||||
username = null;
|
||||
password = null;
|
||||
}
|
||||
|
||||
// 如果解密后没有任何可用凭证,返回空对象,避免把密文当作明文认证参数下发给前端
|
||||
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"));
|
||||
|
||||
Reference in New Issue
Block a user