Compare commits

...

16 Commits

Author SHA1 Message Date
qaiu 4a5240fcbc Merge pull request #203 from qaiu/dependabot/npm_and_yarn/web-front/npm_and_yarn-c42958dede
build(deps): bump axios from 1.16.1 to 1.18.0 in /web-front in the npm_and_yarn group across 1 directory
2026-07-26 08:47:25 +08:00
qaiu ecd365767a Merge pull request #199 from qaiu/dependabot/maven/maven-6421fd6730
chore(deps): bump ch.qos.logback:logback-core from 1.5.32 to 1.5.33 in the maven group across 1 directory
2026-07-26 08:46:01 +08:00
qaiu f358140974 Update README.md 2026-07-25 11:14:19 +08:00
qaiu 541c21f963 Merge pull request #204 from newbie000652/fix/uc-subdirectory-stoken
fix: UC subdirectory listing fails due to missing stoken parameter
2026-07-25 07:58:14 +08:00
Tra bdd253383d docs: add stoken parameter to /v2/getFileList OpenAPI spec 2026-07-24 20:43:04 +08:00
Rune 0fb53d5159 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-24 19:54:22 +08:00
Tra b9ff408bdd fix: UC subdirectory listing fails due to missing stoken parameter
When the frontend requests subdirectory contents for UC drive shares, the
stoken query parameter is mishandled in two places, breaking directory browsing:

1. URLParamUtil.handleTruncatedUrl() does not exclude stoken from the URL
   construction loop. As a result, the stoken value gets appended to the share
   URL (e.g. https://drive.uc.cn/s/xxx?stoken=yyy). The extra query string
   breaks the UC URL regex match in PanDomainTemplate, causing the parser to
   fall back to the default IPanTool.parseFileList() which returns
   "Not implemented yet".

2. ParserApi.getFileList() does not accept stoken as a method parameter and
   does not forward it to ShareLinkInfo.otherParam. Even when the stoken is
   present in the request URL, UcTool.parseFileList() cannot find it and must
   re-authenticate against the UC API — which fails without proper auth cookies.

The fix:
- URLParamUtil: add stoken to the param exclusion list
- ParserApi: add String stoken parameter and put it into otherParam

Both first-level and nested directory listing work correctly after this fix.
2026-07-24 18:04:52 +08:00
qaiu aeb4394cf8 fix GHSA-997r-7xx2-p9x6 2026-07-22 10:25:10 +08:00
dependabot[bot] 8d419d3265 build(deps): bump axios
Bumps the npm_and_yarn group with 1 update in the /web-front directory: [axios](https://github.com/axios/axios).


Updates `axios` from 1.16.1 to 1.18.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.16.1...v1.18.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 07:29:38 +00:00
q b70e30796f ci: package fuller jre for native builds 2026-07-19 09:57:31 +08:00
q d3ef521773 fix: support donated auth for 123 directory downloads 2026-07-19 09:39:55 +08:00
q 2969c92d3d build jre 2026-07-19 02:06:33 +08:00
q 2fde421169 chore: bump version to 0.3.9 2026-07-19 01:11:57 +08:00
q 3494271150 chore: bump version to 0.3.8 2026-07-19 00:55:54 +08:00
q 675d930f86 fix: 123网盘捐赠账号无需临时auth参数即可自动生效,修复token前缀与脏数据问题
- 前端: getCurrentPanType() 补全123网盘域名匹配正则(share.123pan.cn等子域名)
- 前端: 临时认证弹窗补充123云盘(YE)选项及提示文案
- 前端: submitDonateAccount() 按当前authType只提交相关字段,避免切换认证方式后
  残留的用户名/密码脏数据被一并提交
- 后端: URLParamUtil.addParam() 不再无条件用静态yml配置覆盖已生效的临时认证/
  自动捐赠账号回退(增加 __TEMP_AUTH_ADDED 判断)
- 后端: CacheServiceImpl 新增 applyDonatedAccountFallback(),请求未带auth参数
  且无静态配置时自动从捐赠账号池随机取一个可用账号使用,并在解析失败时记录/
  自动禁用失败账号
- 后端: DbServiceImpl.getRandomDonatedAccount() 对非password类型账号强制丢弃
  历史遗留的username/password字段,避免被误当账号密码登录
- 解析器: Ye2Tool 清理token/authorization前缀中的"Bearer ",避免拼接成
  'Bearer Bearer xxx' 导致123接口返回 tokenstring should not contain bearer

已通过本地实测验证: 捐赠123账号后,不带auth参数直接请求
/json/parser?url=https://1815268665.share.123pan.cn/123pan/iaKtVv-r4aCd
可正常返回200及直链
2026-07-18 23:21:56 +08:00
dependabot[bot] 363c603bbb chore(deps): bump ch.qos.logback:logback-core
Bumps the maven group with 1 update in the / directory: [ch.qos.logback:logback-core](https://github.com/qos-ch/logback).


Updates `ch.qos.logback:logback-core` from 1.5.32 to 1.5.33
- [Release notes](https://github.com/qos-ch/logback/releases)
- [Commits](https://github.com/qos-ch/logback/compare/v_1.5.32...v_1.5.33)

---
updated-dependencies:
- dependency-name: ch.qos.logback:logback-core
  dependency-version: 1.5.33
  dependency-type: direct:production
  dependency-group: maven
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-06 02:09:01 +00:00
17 changed files with 452 additions and 159 deletions
+13 -26
View File
@@ -158,37 +158,19 @@ jobs:
path: web-service/target/package
# ============================================================
# jdeps 分析 → 确定所需 JDK 模块
# 选择 JDK 模块
# ============================================================
- name: 分析所需 JDK 模块jdeps
- name: 选择完整 JDK 模块集合
run: |
MAIN_JAR="web-service/target/package/netdisk-fast-download.jar"
LIB_DIR="web-service/target/package/lib"
CP=""
for jar in "$LIB_DIR"/*.jar; do
CP="$CP${CP:+:}$jar"
done
RAW_MODULES=$(jdeps --print-module-deps --ignore-missing-deps --multi-release 17 \
--class-path "$CP" "$MAIN_JAR" 2>/dev/null | head -n 1 | tr -d '\r\n' || true)
if [ -z "$RAW_MODULES" ] || [[ "$RAW_MODULES" == *"Missing"* ]] || [[ "$RAW_MODULES" == *"Error"* ]]; then
# ⚠️ 回退列表:若项目新增了需要 java.* / jdk.* 模块的依赖,需同步更新此处
RAW_MODULES="java.base,java.logging,java.sql,java.naming,java.management,java.xml,jdk.unsupported,java.net.http,java.instrument,java.security.jgss,java.security.sasl,java.desktop,jdk.crypto.ec"
echo "jdeps 分析失败,使用回退模块列表"
else
# 补上 jdeps 无法检测的反射/SPI依赖
RAW_MODULES="$RAW_MODULES,java.desktop,jdk.crypto.ec"
fi
echo "detected modules: $RAW_MODULES"
printf 'JDK_MODULES=%s\n' "$RAW_MODULES" >> $GITHUB_ENV
# 123 网盘/自定义 JS 解析器会通过 javax.script、Nashorn 和 SPI/反射加载模块,
# jdeps 无法稳定识别这些运行时依赖。这里优先保证原生包可运行,使用完整
# JDK 模块集合生成运行时,避免 Windows 精简 JRE 缺 java.scripting/jdk.dynalink 等模块。
printf 'JDK_MODULES=ALL-MODULE-PATH\n' >> $GITHUB_ENV
# ============================================================
# jlink 生成精简 JRE
# jlink 生成完整模块 JRE
# ============================================================
- name: 生成精简 JREjlink
- name: 生成完整模块 JREjlink
run: |
jlink \
--module-path "$JAVA_HOME/jmods" \
@@ -215,6 +197,11 @@ jobs:
ls -la "$JRE_BIN"/*.dll 2>/dev/null || echo "(无 .dll 文件)"
fi
echo "=== 关键 JDK 模块校验 ==="
"native-package/netdisk-fast-download/jre/bin/java" --list-modules | grep -q '^java.scripting@'
"native-package/netdisk-fast-download/jre/bin/java" --list-modules | grep -q '^jdk.dynalink@'
"native-package/netdisk-fast-download/jre/bin/java" --list-modules | grep -q '^jdk.unsupported@'
# ============================================================
# 组装包目录
# ============================================================
+7 -1
View File
@@ -531,7 +531,13 @@ Core模块集成Vert.x实现类似spring的注解式路由API
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=qaiu/netdisk-fast-download&type=Date)](https://star-history.com/#qaiu/netdisk-fast-download&Date)
<a href="https://www.star-history.com/?repos=qaiu%2Fnetdisk-fast-download&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&theme=dark&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
</picture>
</a>
## **免责声明**
- 用户在使用本项目时,应自行承担风险,并确保其行为符合当地法律法规。开发者不对用户因使用本项目而导致的任何后果负责。
+1 -1
View File
@@ -65,7 +65,7 @@
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.11</version>
<version>42.7.12</version>
</dependency>
</dependencies>
@@ -20,6 +20,9 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.InetAddress;
import java.net.URL;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
@@ -201,6 +204,76 @@ public abstract class PanBase implements IPanTool, Closeable {
}
}
/**
* SSRF 防护: 校验通用自定义域名解析器 (CE/Ce4/Kd/Other 等) 即将请求的目标主机,
* 拒绝解析到回环/内网/链路本地/组播等非公网地址的域名, 阻止攻击者通过可控 DNS
* 记录 (或直接填写内网域名) 让服务端向内网/云元数据接口发起请求。
* <p>
* 必须在子类 parse() 中构造出 baseUrl/发起任何 clientSession 请求之前调用。
*
* @param url 从 shareLinkInfo.getShareUrl() 解析出的 URL
* @throws IOException 当主机无法解析或解析结果落入禁止的地址段时抛出
*/
protected static void assertPublicHost(URL url) throws IOException {
String host = url.getHost();
InetAddress[] addresses;
try {
addresses = InetAddress.getAllByName(host);
} catch (UnknownHostException e) {
throw new IOException("无法解析目标主机: " + host, e);
}
if (addresses.length == 0) {
throw new IOException("无法解析目标主机: " + host);
}
for (InetAddress addr : addresses) {
if (isDisallowedAddress(addr)) {
throw new IOException("目标地址不允许访问(内网/回环/链路本地/组播): "
+ host + " -> " + addr.getHostAddress());
}
}
}
/**
* 判断地址是否落入禁止访问的范围: 0.0.0.0/8, 10/8, 127/8, 169.254/16(含云元数据
* 169.254.169.254), 172.16/12, 192.168/16, 100.64/10(CGNAT), 224/4~255/4(组播/保留),
* 以及对应的 IPv6 回环/链路本地/唯一本地地址(fc00::/7)/组播地址; IPv4-映射的 IPv6
* 地址(::ffff:a.b.c.d) 会先还原为 IPv4 再判断,避免绕过。
*/
private static boolean isDisallowedAddress(InetAddress addr) {
byte[] bytes = addr.getAddress();
if (bytes.length == 16 && isIPv4Mapped(bytes)) {
byte[] v4 = new byte[4];
System.arraycopy(bytes, 12, v4, 0, 4);
bytes = v4;
}
if (bytes.length == 4) {
int b0 = bytes[0] & 0xFF;
int b1 = bytes[1] & 0xFF;
if (b0 == 0) return true; // 0.0.0.0/8
if (b0 == 10) return true; // 10.0.0.0/8
if (b0 == 127) return true; // 127.0.0.0/8 loopback
if (b0 == 169 && b1 == 254) return true; // 169.254.0.0/16 (含云元数据 169.254.169.254)
if (b0 == 172 && b1 >= 16 && b1 <= 31) return true; // 172.16.0.0/12
if (b0 == 192 && b1 == 168) return true; // 192.168.0.0/16
if (b0 == 100 && b1 >= 64 && b1 <= 127) return true;// 100.64.0.0/10 CGNAT
return b0 >= 224; // 224.0.0.0/4 组播 + 240.0.0.0/4 保留
}
// IPv6
if (addr.isAnyLocalAddress() || addr.isLoopbackAddress()
|| addr.isLinkLocalAddress() || addr.isSiteLocalAddress()
|| addr.isMulticastAddress()) {
return true;
}
return bytes.length == 16 && (bytes[0] & 0xFE) == 0xFC; // fc00::/7 unique local
}
private static boolean isIPv4Mapped(byte[] b) {
for (int i = 0; i < 10; i++) {
if (b[i] != 0) return false;
}
return (b[10] & 0xFF) == 0xFF && (b[11] & 0xFF) == 0xFF;
}
protected String baseMsg() {
if (shareLinkInfo.getShareUrl() != null) {
return shareLinkInfo.getPanName() + "-" + shareLinkInfo.getType() + ": url=" + shareLinkInfo.getShareUrl();
@@ -37,6 +37,7 @@ public class Ce4Tool extends PanBase {
try {
URL url = new URL(shareLinkInfo.getShareUrl());
assertPublicHost(url);
String baseUrl = url.getProtocol() + "://" + url.getHost();
// 如果有端口,拼接上端口
if (url.getPort() != -1) {
@@ -43,6 +43,7 @@ public class CeTool extends PanBase {
String pwd = shareLinkInfo.getSharePassword();
try {
URL url = new URL(shareLinkInfo.getShareUrl());
assertPublicHost(url);
String baseUrl = url.getProtocol() + "://" + url.getHost();
// 如果有端口,拼接上端口
if (url.getPort() != -1) {
@@ -99,10 +99,26 @@ public class Ye2Tool extends PanBase {
return null;
}
String token = auths.get("token");
if (StringUtils.isNotEmpty(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;
}
return auths.get("authorization");
String trimmed = token.trim();
if (trimmed.regionMatches(true, 0, "Bearer ", 0, 7)) {
trimmed = trimmed.substring(7).trim();
}
return trimmed;
}
private boolean isTokenExpired() {
@@ -625,7 +641,14 @@ public class Ye2Tool extends PanBase {
return;
}
String normalizedShareKey = YeShareHostUtil.normalizeShareKey(shareLinkInfo.getShareKey());
String rawShareKey = paramJson.getString("shareKey");
if (StringUtils.isBlank(rawShareKey)) {
rawShareKey = paramJson.getString("ShareKey");
}
if (StringUtils.isBlank(rawShareKey)) {
rawShareKey = shareLinkInfo.getShareKey();
}
String normalizedShareKey = YeShareHostUtil.normalizeShareKey(rawShareKey);
if (StringUtils.isNotEmpty(normalizedShareKey)) {
JsonObject v2Body = new JsonObject()
.put("ShareKey", normalizedShareKey)
+2 -2
View File
@@ -17,7 +17,7 @@
</modules>
<properties>
<revision>0.3.7</revision>
<revision>0.4.1</revision>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
@@ -35,7 +35,7 @@
<parserVersion>10.2.5</parserVersion>
<jackson.version>2.18.6</jackson.version>
<!-- Logback 最新稳定版 -->
<logback.version>1.5.32</logback.version>
<logback.version>1.5.33</logback.version>
<junit.version>4.13.2</junit.version>
</properties>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "nfd-web",
"version": "0.3.7",
"version": "0.4.1",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
@@ -13,7 +13,7 @@
"@element-plus/icons-vue": "^2.3.1",
"@monaco-editor/loader": "^1.4.0",
"@vueuse/core": "^11.2.0",
"axios": "1.16.1",
"axios": "1.18.0",
"clipboard": "^2.0.11",
"core-js": "^3.8.3",
"crypto-js": "^4.2.0",
+16 -7
View File
@@ -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)
+10
View File
@@ -386,6 +386,16 @@
"type": "string",
"example": "uuid123"
}
},
{
"name": "stoken",
"in": "query",
"required": false,
"description": "分享 token,用于子目录解析时复用认证状态",
"schema": {
"type": "string",
"example": "OASBe5qM0pg2VvvyLM..."
}
}
],
"responses": {
@@ -0,0 +1,203 @@
package cn.qaiu.lz.common.util;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.lz.web.model.AuthParam;
import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.parser.ParserCreate;
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.http.HttpServerRequest;
import io.vertx.core.json.JsonObject;
import io.vertx.core.shareddata.LocalMap;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import java.util.Map;
@Slf4j
public class ParserAuthUtil {
public static final String SKIP_CLIENT_LINKS = "_skipClientLinks";
public static final String TEMP_AUTH_ADDED = "__TEMP_AUTH_ADDED";
public static final String DONATED_ACCOUNT_TOKEN = "__AUTO_DONATED_ACCOUNT_TOKEN";
private ParserAuthUtil() {
}
public static JsonObject buildOtherParam(HttpServerRequest request, String auth, String requestOrigin) {
return buildOtherParam(request, auth, requestOrigin, false);
}
public static JsonObject buildOtherParam(HttpServerRequest request, String auth, String requestOrigin,
boolean skipClientLinks) {
JsonObject otherParam = JsonObject.of(
"UA", request.headers().get("user-agent"),
"_requestOrigin", requestOrigin
);
if (skipClientLinks) {
otherParam.put(SKIP_CLIENT_LINKS, true);
}
if (StringUtils.isNotBlank(auth)) {
AuthParam authParam = AuthParamCodec.decode(auth);
if (authParam != null && authParam.hasValidAuth()) {
otherParam.put("authType", authParam.getAuthType());
otherParam.put("authToken", authParam.getPrimaryCredential());
otherParam.put("authPassword", authParam.getPassword());
otherParam.put("authInfo1", authParam.getExt1());
otherParam.put("authInfo2", authParam.getExt2());
otherParam.put("authInfo3", authParam.getExt3());
otherParam.put("authInfo4", authParam.getExt4());
otherParam.put("authInfo5", authParam.getExt5());
if (StringUtils.isNotBlank(authParam.getDonatedAccountToken())) {
otherParam.put("donatedAccountToken", authParam.getDonatedAccountToken());
}
log.debug("已解码认证参数: authType={}", authParam.getAuthType());
}
}
return otherParam;
}
public static Future<Void> applyAuthParamsAndDonatedFallback(ParserCreate parserCreate, JsonObject otherParam,
DbService dbService) {
JsonObject params = otherParam == null ? new JsonObject() : otherParam;
parserCreate.getShareLinkInfo().getOtherParam().putAll(params.getMap());
if (params.containsKey("authType") || params.containsKey("authToken")) {
log.debug("从otherParam中检测到临时认证参数");
URLParamUtil.addTempAuthParam(parserCreate,
params.getString("authType"),
params.getString("authToken"),
params.getString("authPassword"),
params.getString("authInfo1"),
params.getString("authInfo2"),
params.getString("authInfo3"),
params.getString("authInfo4"),
params.getString("authInfo5"));
}
return applyDonatedAccountFallback(parserCreate, dbService);
}
public static void recordDonatedAccountFailureIfNeeded(DbService dbService, JsonObject otherParam,
Throwable cause) {
if (!isLikelyAuthFailure(cause) || otherParam == null) {
return;
}
String donatedAccountToken = otherParam.getString("donatedAccountToken");
if (StringUtils.isBlank(donatedAccountToken)) {
return;
}
dbService.recordDonatedAccountFailureByToken(donatedAccountToken)
.onFailure(e -> log.warn("记录捐赠账号失败次数失败", e));
}
public static void recordAutoDonatedFailureIfNeeded(DbService dbService, ShareLinkInfo shareLinkInfo,
Throwable cause) {
if (shareLinkInfo == null || !isLikelyAuthFailure(cause)) {
return;
}
Object tokenObj = shareLinkInfo.getOtherParam().get(DONATED_ACCOUNT_TOKEN);
if (!(tokenObj instanceof String) || StringUtils.isBlank((String) tokenObj)) {
return;
}
dbService.recordDonatedAccountFailureByToken((String) tokenObj)
.onFailure(e -> log.warn("记录自动捐赠账号失败次数失败", e));
}
private static Future<Void> applyDonatedAccountFallback(ParserCreate parserCreate, DbService dbService) {
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();
});
}
private static 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 static boolean isLikelyAuthFailure(Throwable cause) {
if (cause == null) {
return false;
}
String msg = cause.getMessage();
if (StringUtils.isBlank(msg)) {
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("认证");
}
}
@@ -68,9 +68,9 @@ public class URLParamUtil {
boolean firstParam = !decodedUrl.contains("?");
for (String paramName : params.names()) {
// 忽略 "url", "pwd", "dirId", "uuid", "auth" 参数(这些参数单独处理,不应拼接到分享URL中)
// 忽略 "url", "pwd", "dirId", "uuid", "auth", "stoken" 参数(这些参数单独处理,不应拼接到分享URL中)
if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId")
&& !paramName.equals("uuid") && !paramName.equals("auth")) {
&& !paramName.equals("uuid") && !paramName.equals("auth") && !paramName.equals("stoken")) {
if (firstParam) {
urlBuilder.append("?");
firstParam = false;
@@ -100,6 +100,11 @@ public class URLParamUtil {
}
}
if (localMap.containsKey(ConfigConstant.AUTHS)) {
// 如果本次请求已经通过 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)) {
// 需要处理引号
@@ -117,6 +122,7 @@ public class URLParamUtil {
parserCreate.getShareLinkInfo().getOtherParam().put(ConfigConstant.AUTHS, entries);
}
}
}
String linkPrefix = SharedDataUtil.getJsonConfig("server").getString("domainName");
if (StringUtils.isBlank(linkPrefix)) {
@@ -5,6 +5,7 @@ import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.lz.common.cache.CacheManager;
import cn.qaiu.lz.common.util.AuthParamCodec;
import cn.qaiu.lz.common.util.ParserAuthUtil;
import cn.qaiu.lz.common.util.URLParamUtil;
import cn.qaiu.lz.web.model.AuthParam;
import cn.qaiu.lz.web.model.CacheLinkInfo;
@@ -159,7 +160,8 @@ public class ParserApi {
}
@RouteMapping("/getFileList")
public Future<List<FileInfo>> getFileList(HttpServerRequest request, String pwd, String dirId, String uuid) {
public Future<List<FileInfo>> getFileList(HttpServerRequest request, String pwd, String dirId, String uuid,
String stoken, String auth) {
String url = URLParamUtil.parserParams(request);
ParserCreate parserCreate;
try {
@@ -168,21 +170,34 @@ public class ParserApi {
return Future.failedFuture(e);
}
String linkPrefix = getLinkPrefix(request);
JsonObject otherParam = ParserAuthUtil.buildOtherParam(request, auth, linkPrefix);
parserCreate.getShareLinkInfo().getOtherParam().put("domainName", linkPrefix);
parserCreate.getShareLinkInfo().getOtherParam().put("_requestOrigin", linkPrefix);
if (StringUtils.isNotBlank(dirId)) {
parserCreate.getShareLinkInfo().getOtherParam().put("dirId", dirId);
}
if (StringUtils.isNotBlank(stoken)) {
parserCreate.getShareLinkInfo().getOtherParam().put("stoken", stoken);
}
if (StringUtils.isNotBlank(uuid)) {
parserCreate.getShareLinkInfo().getOtherParam().put("uuid", uuid);
}
return ParserAuthUtil.applyAuthParamsAndDonatedFallback(parserCreate, otherParam, dbService)
.compose(v -> {
URLParamUtil.addParam(parserCreate);
IPanTool tool = parserCreate.createTool();
return IPanTool.closeAfter(tool, tool::parseFileList);
return IPanTool.closeAfter(tool, tool::parseFileList)
.onFailure(t -> {
ParserAuthUtil.recordDonatedAccountFailureIfNeeded(dbService, otherParam, t);
ParserAuthUtil.recordAutoDonatedFailureIfNeeded(dbService,
parserCreate.getShareLinkInfo(), t);
});
});
}
// 目录解析下载文件
// @RouteMapping("/getFileDownUrl/:type/:param")
public Future<String> getFileDownUrl(HttpServerRequest request, String type, String param) {
public Future<String> getFileDownUrl(HttpServerRequest request, String type, String param, String auth) {
ParserCreate parserCreate = ParserCreate.fromType(type).shareKey("-") // shareKey not null
.setShareLinkInfoPwd("-");
@@ -198,17 +213,28 @@ public class ParserApi {
// domainName
String linkPrefix = getLinkPrefix(request);
JsonObject otherParam = ParserAuthUtil.buildOtherParam(request, auth, linkPrefix, true);
shareLinkInfo.getOtherParam().put("domainName", linkPrefix);
shareLinkInfo.getOtherParam().put("_requestOrigin", linkPrefix);
return ParserAuthUtil.applyAuthParamsAndDonatedFallback(parserCreate, otherParam, dbService)
.compose(v -> {
URLParamUtil.addParam(parserCreate);
IPanTool tool = parserCreate.createTool();
return IPanTool.closeAfter(tool, tool::parseById);
return IPanTool.closeAfter(tool, tool::parseById)
.onFailure(t -> {
ParserAuthUtil.recordDonatedAccountFailureIfNeeded(dbService, otherParam, t);
ParserAuthUtil.recordAutoDonatedFailureIfNeeded(dbService,
parserCreate.getShareLinkInfo(), t);
});
});
}
@RouteMapping("/redirectUrl/:type/:param")
public Future<Void> redirectUrl(HttpServerRequest request, HttpServerResponse response, String type, String param) {
public Future<Void> redirectUrl(HttpServerRequest request, HttpServerResponse response, String type, String param,
String auth) {
Promise<Void> promise = Promise.promise();
getFileDownUrl(request, type, param)
getFileDownUrl(request, type, param, auth)
.onSuccess(res -> {
ResponseUtil.redirect(response, res, promise);
})
@@ -283,11 +309,12 @@ public class ParserApi {
@RouteMapping("/viewUrl/:type/:param")
public Future<Void> viewUrl(HttpServerRequest request, HttpServerResponse response, String type, String param) {
public Future<Void> viewUrl(HttpServerRequest request, HttpServerResponse response, String type, String param,
String auth) {
Promise<Void> promise = Promise.promise();
String viewPrefix = SharedDataUtil.getJsonConfig("server").getString("previewURL");
getFileDownUrl(request, type, param)
getFileDownUrl(request, type, param, auth)
.onSuccess(res -> {
String url = viewPrefix + URLEncoder.encode(res, StandardCharsets.UTF_8);
ResponseUtil.redirect(response, url, promise);
@@ -1,8 +1,7 @@
package cn.qaiu.lz.web.controller;
import cn.qaiu.lz.common.util.AuthParamCodec;
import cn.qaiu.lz.common.util.ParserAuthUtil;
import cn.qaiu.lz.common.util.URLParamUtil;
import cn.qaiu.lz.web.model.AuthParam;
import cn.qaiu.lz.web.model.CacheLinkInfo;
import cn.qaiu.lz.web.service.CacheService;
import cn.qaiu.lz.web.service.DbService;
@@ -29,7 +28,7 @@ import lombok.extern.slf4j.Slf4j;
@RouteHandler("/")
public class ServerApi {
private static final String SKIP_CLIENT_LINKS = "_skipClientLinks";
private static final String SKIP_CLIENT_LINKS = ParserAuthUtil.SKIP_CLIENT_LINKS;
private final CacheService cacheService = AsyncServiceUtil.getAsyncServiceInstance(CacheService.class);
private final DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class);
@@ -47,7 +46,7 @@ public class ServerApi {
addCacheHeaders(response, res),
res.getDirectLink(), promise))
.onFailure(t -> {
recordDonatedAccountFailureIfNeeded(otherParam, t);
ParserAuthUtil.recordDonatedAccountFailureIfNeeded(dbService, otherParam, t);
promise.tryFail(t);
});
return promise.future();
@@ -58,14 +57,14 @@ public class ServerApi {
String url = URLParamUtil.parserParams(request);
JsonObject otherParam = buildOtherParam(request, auth);
return cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam)
.onFailure(t -> recordDonatedAccountFailureIfNeeded(otherParam, t));
.onFailure(t -> ParserAuthUtil.recordDonatedAccountFailureIfNeeded(dbService, otherParam, t));
}
public Future<CacheLinkInfo> parseJsonForRedirect(HttpServerRequest request, String pwd, String auth) {
String url = URLParamUtil.parserParams(request);
JsonObject otherParam = buildOtherParam(request, auth, true);
return cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam)
.onFailure(t -> recordDonatedAccountFailureIfNeeded(otherParam, t));
.onFailure(t -> ParserAuthUtil.recordDonatedAccountFailureIfNeeded(dbService, otherParam, t));
}
@RouteMapping(value = "/json/:type/:key", method = RouteMethod.GET)
@@ -146,66 +145,6 @@ public class ServerApi {
}
private JsonObject buildOtherParam(HttpServerRequest request, String auth, boolean skipClientLinks) {
JsonObject otherParam = JsonObject.of("UA", request.headers().get("user-agent"), "_requestOrigin", resolveOrigin(request));
if (skipClientLinks) {
otherParam.put(SKIP_CLIENT_LINKS, true);
}
// 解码认证参数
if (auth != null && !auth.isEmpty()) {
AuthParam authParam = AuthParamCodec.decode(auth);
if (authParam != null && authParam.hasValidAuth()) {
// 将认证参数放入 otherParam
otherParam.put("authType", authParam.getAuthType());
otherParam.put("authToken", authParam.getPrimaryCredential());
otherParam.put("authPassword", authParam.getPassword());
otherParam.put("authInfo1", authParam.getExt1());
otherParam.put("authInfo2", authParam.getExt2());
otherParam.put("authInfo3", authParam.getExt3());
otherParam.put("authInfo4", authParam.getExt4());
otherParam.put("authInfo5", authParam.getExt5());
if (authParam.getDonatedAccountToken() != null && !authParam.getDonatedAccountToken().isBlank()) {
otherParam.put("donatedAccountToken", authParam.getDonatedAccountToken());
}
log.debug("已解码认证参数: authType={}", authParam.getAuthType());
}
}
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("认证");
return ParserAuthUtil.buildOtherParam(request, auth, resolveOrigin(request), skipClientLinks);
}
}
@@ -5,14 +5,17 @@ import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.lz.common.cache.CacheConfigLoader;
import cn.qaiu.lz.common.cache.CacheManager;
import cn.qaiu.lz.common.cache.CacheTotalField;
import cn.qaiu.lz.common.util.ParserAuthUtil;
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 io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonObject;
@@ -27,9 +30,10 @@ import java.util.Map;
@Slf4j
public class CacheServiceImpl implements CacheService {
private static final String SKIP_CLIENT_LINKS = "_skipClientLinks";
private static final String SKIP_CLIENT_LINKS = ParserAuthUtil.SKIP_CLIENT_LINKS;
private final CacheManager cacheManager = new CacheManager();
private final DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class);
static {
// 服务类加载时注册缓存定时清理任务
@@ -71,7 +75,9 @@ public class CacheServiceImpl implements CacheService {
promise.fail(cause);
return;
}
IPanTool.closeAfter(tool, tool::parse).onSuccess(redirectUrl -> {
IPanTool.closeAfter(tool, tool::parse).onFailure(err -> {
ParserAuthUtil.recordAutoDonatedFailureIfNeeded(dbService, shareLinkInfo, err);
}).onSuccess(redirectUrl -> {
// 使用 effectiveCacheDuration
long expires = System.currentTimeMillis() + effectiveCacheDuration * 60 * 1000L;
result.setDirectLink(redirectUrl);
@@ -278,8 +284,9 @@ public class CacheServiceImpl implements CacheService {
} catch (Exception e) {
return Future.failedFuture(e);
}
parserCreate.getShareLinkInfo().getOtherParam().putAll(otherParam.getMap());
return getAndSaveCachedShareLink(parserCreate);
ParserCreate finalParserCreate = parserCreate;
return ParserAuthUtil.applyAuthParamsAndDonatedFallback(finalParserCreate, otherParam, dbService)
.compose(v -> getAndSaveCachedShareLink(finalParserCreate));
}
@Override
@@ -290,22 +297,8 @@ public class CacheServiceImpl implements CacheService {
} catch (Exception e) {
return Future.failedFuture(e);
}
parserCreate.getShareLinkInfo().getOtherParam().putAll(otherParam.getMap());
// 检查是否有临时认证参数
if (otherParam.containsKey("authType") || otherParam.containsKey("authToken")) {
log.debug("从otherParam中检测到临时认证参数");
URLParamUtil.addTempAuthParam(parserCreate,
otherParam.getString("authType"),
otherParam.getString("authToken"),
otherParam.getString("authPassword"),
otherParam.getString("authInfo1"),
otherParam.getString("authInfo2"),
otherParam.getString("authInfo3"),
otherParam.getString("authInfo4"),
otherParam.getString("authInfo5"));
}
return getAndSaveCachedShareLink(parserCreate);
ParserCreate finalParserCreate = parserCreate;
return ParserAuthUtil.applyAuthParamsAndDonatedFallback(finalParserCreate, otherParam, dbService)
.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"));