mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-08-26 03:22:02 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4995173de2 | |||
| aeb4394cf8 | |||
| b70e30796f | |||
| d3ef521773 | |||
| 2969c92d3d | |||
| 2fde421169 | |||
| 3494271150 | |||
| 675d930f86 | |||
| 5e20ed5050 | |||
| 91f4fcaa76 | |||
| 0754fe20f1 | |||
| 034fd20d3b | |||
| 24bf98bca9 | |||
| 275f6b9e6d | |||
| 5db9e422f4 |
+13
-26
@@ -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: 生成精简 JRE(jlink)
|
||||
- name: 生成完整模块 JRE(jlink)
|
||||
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@'
|
||||
|
||||
# ============================================================
|
||||
# 组装包目录
|
||||
# ============================================================
|
||||
|
||||
@@ -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>
|
||||
@@ -16,13 +18,9 @@ QQ交流群:1017480890
|
||||

|
||||
|
||||
|
||||
## 国内镜像
|
||||
|
||||
本项目同步托管于 **AtomGit**,国内访问更流畅:👉 [https://atomgit.com/QAIU/netdisk-fast-download](https://atomgit.com/QAIU/netdisk-fast-download)
|
||||
|
||||
|
||||
## 介绍
|
||||
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
||||
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/永硕E盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
||||
|
||||
[官方文档](https://nfd-parser.github.io/)
|
||||
[API接入](https://nfdparser.apifox.cn/)
|
||||
@@ -40,12 +38,12 @@ curl -LOJ "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/Tk1F2kGQ&
|
||||
```shell
|
||||
wget -O bilibili.mp4 "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/Tk1F2kGQ&pwd=1234"
|
||||
```
|
||||
或者使用浏览器[直接访问](https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fshare.feijipan.com%2Fs%2FTk1F2kGQ&name=bilibili.mp4&ext=mp4):
|
||||
或者使用浏览器[直接访问](https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fwww.ilanzou.com%2Fs%2FCDx6xKbT&name=bilibili.mp4&ext=mp4):
|
||||
```
|
||||
### 调用演示站下载:
|
||||
https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/Tk1F2kGQ&pwd=1234
|
||||
https://lz.qaiu.top/parser?url=https://www.ilanzou.com/s/CDx6xKbT&pwd=1234
|
||||
### 调用演示站预览:
|
||||
https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fshare.feijipan.com%2Fs%2FTk1F2kGQ&name=bilibili.mp4&ext=mp4
|
||||
https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fwww.ilanzou.com%2Fs%2FCDx6xKbT&name=bilibili.mp4&ext=mp4
|
||||
|
||||
```
|
||||
|
||||
@@ -71,13 +69,14 @@ 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/)
|
||||
- [QQ邮箱云盘-qqw](https://mail.qq.com/)
|
||||
- [QQ闪传-qqsc](https://nutty.qq.com/nutty/ssr/26797.html)
|
||||
- [城通网盘-ct](https://www.ctfile.com)
|
||||
- [永硕E盘-ys](https://www.ysepan.com/)(空间分享,如 `https://xxx.ysepan.com/`,密码为空间访问密码;多文件请用文件列表接口)
|
||||
- [网易云音乐分享链接-mnes](https://music.163.com)
|
||||
- [酷狗音乐分享链接-mkgs](https://www.kugou.com)
|
||||
- [酷我音乐分享链接-mkws](https://kuwo.cn)
|
||||
@@ -178,6 +177,7 @@ GET /json/getFileList?url={分享链接}&pwd={密码}
|
||||
| UC网盘(UC) | **必须** | 必须配置 Cookie 才能解析 |
|
||||
| 小飞机网盘(FJ) | 可选 | 大文件(>100MB)需要认证 |
|
||||
| 蓝奏优享(IZ) | 可选 | 大文件需要认证 |
|
||||
| 123网盘(YE) | 可选 | 需要下载大文件/需要登录的分享时才需要认证,支持账号密码或 token/authorization |
|
||||
|
||||
**使用示例**:
|
||||
```
|
||||
@@ -198,6 +198,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` 参数值
|
||||
@@ -278,9 +310,12 @@ json返回数据格式示例:
|
||||
"timestamp": 1736489219402
|
||||
}
|
||||
```
|
||||
#### 3. 文件夹解析(仅支持蓝奏云/蓝奏优享/小飞机网盘)
|
||||
#### 3. 文件夹解析(支持蓝奏云/蓝奏优享/小飞机/永硕E盘等)
|
||||
/v2/getFileList?url=分享链接&pwd=分享密码
|
||||
|
||||
永硕E盘(`ys`)空间链接先返回目录列表,再带 `dirId` 获取目录内文件:
|
||||
`/v2/getFileList?url=https://xxx.ysepan.com/&pwd=空间密码&dirId=目录编号`
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
@@ -336,6 +371,7 @@ json返回数据格式示例:
|
||||
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
|
||||
| 123云盘 | √ | √ | 2T | 100G(>100M需要登录) |
|
||||
| 文叔叔 | √ | √ | 10G | 5GB |
|
||||
| 永硕E盘 | √ | √(空间密码) | 视套餐 | 视套餐 |
|
||||
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
|
||||
| 夸克网盘 | x | √ | 10G | 不限大小 |
|
||||
| UC网盘 | x | √ | 10G | 不限大小 |
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -208,7 +208,7 @@ public enum PanDomainTemplate {
|
||||
123795.com
|
||||
*/
|
||||
YE("123网盘",
|
||||
compile("https://www\\.(" +
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)*(" +
|
||||
"123254\\.com|" +
|
||||
"123957\\.com|" +
|
||||
"123295\\.com|" +
|
||||
@@ -232,7 +232,7 @@ public enum PanDomainTemplate {
|
||||
"123635\\.com|" +
|
||||
"123242\\.com|" +
|
||||
"123795\\.com" +
|
||||
")/s/(?<KEY>[a-zA-Z0-9_-]+)(?:\\.html)?"),
|
||||
")/(?:(?:s|123pan)/|(?:[^/?#]+/)+)?(?<KEY>[a-zA-Z0-9]+-[a-zA-Z0-9]+|[a-zA-Z0-9_-]+)(?:\\.html)?(?:\\?.*)?"),
|
||||
"https://www.123pan.com/s/{shareKey}",
|
||||
Ye2Tool.class),
|
||||
// https://www.ecpan.cn/web/#/yunpanProxy?path=%2F%23%2Fdrive%2Foutside&data={code}&isShare=1
|
||||
@@ -377,6 +377,13 @@ public enum PanDomainTemplate {
|
||||
MmgTool.class),
|
||||
// =====================私有盘解析==========================
|
||||
|
||||
// 永硕E盘空间分享:https://qaiu.ysepan.com/ (空间名即 shareKey,密码为空间访问密码)
|
||||
YS("永硕E盘",
|
||||
compile("https?://(?!(?:www|zy|ht|api|c\\d+|ys-[a-zA-Z0-9]+)\\.)(?<KEY>[a-zA-Z\\d-]+)\\.(?:ysepan|ys168)\\.com/?(?:\\?.*)?"),
|
||||
"https://{shareKey}.ysepan.com/",
|
||||
"https://www.ysepan.com/",
|
||||
YsTool.class),
|
||||
|
||||
// Cloudreve自定义域名解析, 解析器CeTool兜底策略, 即任意域名如果匹配不到对应的规则, 则由CeTool统一处理,
|
||||
// 如果不属于Cloudreve盘 则调用下一个自定义域名解析器, 若都处理不了则抛出异常, 这种匹配模式类似责任链
|
||||
// http(s)://pan.huang1111.cn/s/xxx
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package cn.qaiu.parser;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Parser token cache keyed by parser type and account identity.
|
||||
*/
|
||||
public final class TokenCache {
|
||||
|
||||
private static final Map<String, String> TOKENS = new ConcurrentHashMap<>();
|
||||
private static final Map<String, Long> EXPIRES = new ConcurrentHashMap<>();
|
||||
|
||||
private TokenCache() {
|
||||
}
|
||||
|
||||
public static String key(String type, String accountId) {
|
||||
return type + ":" + (StringUtils.isBlank(accountId) ? "_default" : accountId);
|
||||
}
|
||||
|
||||
public static void putToken(String key, String token) {
|
||||
if (StringUtils.isBlank(key) || StringUtils.isBlank(token)) {
|
||||
return;
|
||||
}
|
||||
TOKENS.put(key, token);
|
||||
}
|
||||
|
||||
public static String getToken(String key) {
|
||||
if (StringUtils.isBlank(key)) {
|
||||
return null;
|
||||
}
|
||||
if (isExpired(key)) {
|
||||
TOKENS.remove(key);
|
||||
EXPIRES.remove(key);
|
||||
return null;
|
||||
}
|
||||
return TOKENS.get(key);
|
||||
}
|
||||
|
||||
public static void putExpire(String key, long expireTimeMillis) {
|
||||
if (StringUtils.isBlank(key)) {
|
||||
return;
|
||||
}
|
||||
EXPIRES.put(key, expireTimeMillis);
|
||||
}
|
||||
|
||||
public static boolean isExpired(String key) {
|
||||
Long expireTimeMillis = EXPIRES.get(key);
|
||||
return expireTimeMillis != null && System.currentTimeMillis() > expireTimeMillis;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,620 @@
|
||||
package cn.qaiu.parser.impl;
|
||||
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.PanBase;
|
||||
import cn.qaiu.util.CommonUtils;
|
||||
import cn.qaiu.util.FileSizeConverter;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.client.HttpRequest;
|
||||
import io.vertx.ext.web.client.HttpResponse;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 永硕E盘 (ysepan.com / ys168.com)
|
||||
* <p>
|
||||
* 空间分享形如 https://{space}.ysepan.com/ ,需空间访问密码时通过 sharePassword 传入。
|
||||
*/
|
||||
public class YsTool extends PanBase {
|
||||
|
||||
private static final String BROWSER_UA =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
||||
+ "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
|
||||
|
||||
private static final Pattern ANTIFORGERY_PATTERN =
|
||||
Pattern.compile("RequestVerificationToken'\\s*:\\s*'([^']+)'");
|
||||
private static final Pattern HTXX_PATTERN =
|
||||
Pattern.compile("window\\.htxx\\s*=\\s*(\\{.*?});", Pattern.DOTALL);
|
||||
private static final Pattern JWT_COOKIE_PATTERN =
|
||||
Pattern.compile("jwttk_[^=]+=([^;\\s]+)");
|
||||
|
||||
private static final String PARAM_DIR_ID = "dirId";
|
||||
|
||||
public YsTool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parse() {
|
||||
ensureSession().onSuccess(session -> {
|
||||
Object dirIdObj = shareLinkInfo.getOtherParam().get(PARAM_DIR_ID);
|
||||
if (dirIdObj != null && StringUtils.isNotBlank(dirIdObj.toString())) {
|
||||
int dirId = Integer.parseInt(dirIdObj.toString());
|
||||
fetchFiles(session, dirId).onSuccess(filesResp -> {
|
||||
List<JsonObject> files = downloadableFiles(filesResp);
|
||||
if (files.isEmpty()) {
|
||||
fail("目录内没有可下载文件");
|
||||
return;
|
||||
}
|
||||
completeDownload(session, filesResp, files.get(0));
|
||||
}).onFailure(err -> fail(err, err.getMessage()));
|
||||
return;
|
||||
}
|
||||
|
||||
fetchDirectories(session).compose(dirs -> collectDownloadableFiles(session, dirs))
|
||||
.onSuccess(collected -> {
|
||||
if (collected.isEmpty()) {
|
||||
fail("空间内没有可下载文件,请检查密码或目录权限");
|
||||
return;
|
||||
}
|
||||
if (collected.size() > 1) {
|
||||
fail("空间包含多个文件(共{}个),请使用文件列表接口后再按文件解析", collected.size());
|
||||
return;
|
||||
}
|
||||
CollectedFile only = collected.get(0);
|
||||
completeDownload(session, only.filesResp, only.file);
|
||||
}).onFailure(err -> fail(err, err.getMessage()));
|
||||
}).onFailure(err -> fail(err, err.getMessage()));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<List<FileInfo>> parseFileList() {
|
||||
Promise<List<FileInfo>> listPromise = Promise.promise();
|
||||
ensureSession().onSuccess(session -> {
|
||||
Object dirIdObj = shareLinkInfo.getOtherParam().get(PARAM_DIR_ID);
|
||||
if (dirIdObj != null && StringUtils.isNotBlank(dirIdObj.toString())) {
|
||||
int dirId = Integer.parseInt(dirIdObj.toString());
|
||||
fetchFiles(session, dirId).onSuccess(filesResp -> {
|
||||
try {
|
||||
listPromise.complete(mapFiles(session, dirId, filesResp));
|
||||
} catch (Exception e) {
|
||||
listPromise.fail(baseMsg() + " - 解析文件列表失败: " + e.getMessage());
|
||||
}
|
||||
}).onFailure(listPromise::fail);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchDirectories(session).onSuccess(dirs -> {
|
||||
try {
|
||||
listPromise.complete(mapDirectories(session, dirs));
|
||||
} catch (Exception e) {
|
||||
listPromise.fail(baseMsg() + " - 解析目录列表失败: " + e.getMessage());
|
||||
}
|
||||
}).onFailure(listPromise::fail);
|
||||
}).onFailure(listPromise::fail);
|
||||
return listPromise.future();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parseById() {
|
||||
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
|
||||
if (paramJson == null) {
|
||||
fail("缺少 paramJson 参数");
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
String downloadUrl = paramJson.getString("downloadUrl");
|
||||
if (StringUtils.isNotBlank(downloadUrl)) {
|
||||
completeWithMeta(downloadUrl, downloadHeaders(paramJson.getString("referer")));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
String space = paramJson.getString("space", spaceName());
|
||||
String xzpz = paramJson.getString("xzpz");
|
||||
String pz = paramJson.getString("pz");
|
||||
String fwq = paramJson.getString("fwq");
|
||||
String fileName = paramJson.getString("fileName");
|
||||
if (StringUtils.isAnyBlank(space, xzpz, pz, fwq, fileName)) {
|
||||
fail("下载参数不完整: {}", paramJson);
|
||||
return promise.future();
|
||||
}
|
||||
String url = buildDownloadUrl(space, xzpz, pz, fwq, fileName, true);
|
||||
completeWithMeta(url, downloadHeaders(paramJson.getString("referer", spaceOrigin())));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private Future<Session> ensureSession() {
|
||||
Promise<Session> p = Promise.promise();
|
||||
String space = spaceName();
|
||||
if (StringUtils.isBlank(space)) {
|
||||
p.fail(baseMsg() + " - 空间名为空");
|
||||
return p.future();
|
||||
}
|
||||
|
||||
String origin = spaceOrigin();
|
||||
clientSession.getAbs(origin + "/")
|
||||
.putHeader("User-Agent", BROWSER_UA)
|
||||
.putHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
.send()
|
||||
.compose(res -> handleSpaceHome(origin, space, res))
|
||||
.onSuccess(p::complete)
|
||||
.onFailure(p::fail);
|
||||
return p.future();
|
||||
}
|
||||
|
||||
private Future<Session> handleSpaceHome(String origin, String space, HttpResponse<Buffer> res) {
|
||||
String html = res.bodyAsString() != null ? res.bodyAsString() : "";
|
||||
String jwt = extractJwt(res);
|
||||
JsonObject htxx = extractHtxx(html);
|
||||
|
||||
// 已进入空间
|
||||
if (htxx != null && StringUtils.isNotBlank(htxx.getString("qqdz"))) {
|
||||
if (StringUtils.isBlank(jwt)) {
|
||||
return Future.failedFuture(baseMsg() + " - 无法获取会话令牌");
|
||||
}
|
||||
return Future.succeededFuture(toSession(origin, space, jwt, htxx));
|
||||
}
|
||||
|
||||
// 需要空间密码
|
||||
if (html.contains("VerifyPassword") || html.contains("loginPopup")) {
|
||||
String pwd = shareLinkInfo.getSharePassword();
|
||||
if (StringUtils.isBlank(pwd)) {
|
||||
return Future.failedFuture(baseMsg() + " - 空间需要访问密码");
|
||||
}
|
||||
String antiforgery = extractAntiforgery(html);
|
||||
if (StringUtils.isBlank(antiforgery)) {
|
||||
return Future.failedFuture(baseMsg() + " - 无法获取防伪令牌");
|
||||
}
|
||||
return verifyPassword(origin, space, pwd, antiforgery)
|
||||
.compose(verifiedJwt -> reloadSpace(origin, space, verifiedJwt));
|
||||
}
|
||||
|
||||
return Future.failedFuture(baseMsg() + " - 无法解析空间信息");
|
||||
}
|
||||
|
||||
private Future<String> verifyPassword(String origin, String space, String pwd, String antiforgery) {
|
||||
Promise<String> p = Promise.promise();
|
||||
JsonObject body = new JsonObject()
|
||||
.put("password", pwd)
|
||||
.put("dlmc", space)
|
||||
.put("remember", false);
|
||||
|
||||
clientSession.postAbs(origin + "/?handler=VerifyPassword")
|
||||
.putHeader("User-Agent", BROWSER_UA)
|
||||
.putHeader("Content-Type", "application/json")
|
||||
.putHeader("Origin", origin)
|
||||
.putHeader("Referer", origin + "/")
|
||||
.putHeader("RequestVerificationToken", antiforgery)
|
||||
.sendJsonObject(body)
|
||||
.onSuccess(res -> {
|
||||
try {
|
||||
JsonObject json = res.bodyAsJsonObject();
|
||||
if (json == null || !Boolean.TRUE.equals(json.getBoolean("success"))) {
|
||||
String msg = json != null ? json.getString("message", "密码错误") : "密码验证失败";
|
||||
p.fail(baseMsg() + " - " + msg);
|
||||
return;
|
||||
}
|
||||
String jwt = extractJwt(res);
|
||||
if (StringUtils.isBlank(jwt)) {
|
||||
p.fail(baseMsg() + " - 密码验证成功但未返回会话令牌");
|
||||
return;
|
||||
}
|
||||
p.complete(jwt);
|
||||
} catch (Exception e) {
|
||||
p.fail(baseMsg() + " - 密码验证响应异常: " + e.getMessage());
|
||||
}
|
||||
})
|
||||
.onFailure(t -> p.fail(baseMsg() + " - 密码验证请求失败: " + t.getMessage()));
|
||||
return p.future();
|
||||
}
|
||||
|
||||
private Future<Session> reloadSpace(String origin, String space, String verifiedJwt) {
|
||||
Promise<Session> p = Promise.promise();
|
||||
clientSession.getAbs(origin + "/")
|
||||
.putHeader("User-Agent", BROWSER_UA)
|
||||
.putHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
.send()
|
||||
.onSuccess(res -> {
|
||||
String html = res.bodyAsString() != null ? res.bodyAsString() : "";
|
||||
JsonObject htxx = extractHtxx(html);
|
||||
String jwt = StringUtils.defaultIfBlank(extractJwt(res), verifiedJwt);
|
||||
if (htxx == null || StringUtils.isBlank(htxx.getString("qqdz"))) {
|
||||
p.fail(baseMsg() + " - 密码验证后仍无法进入空间");
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isBlank(jwt)) {
|
||||
p.fail(baseMsg() + " - 密码验证后无法获取会话令牌");
|
||||
return;
|
||||
}
|
||||
p.complete(toSession(origin, space, jwt, htxx));
|
||||
})
|
||||
.onFailure(t -> p.fail(baseMsg() + " - 重新加载空间失败: " + t.getMessage()));
|
||||
return p.future();
|
||||
}
|
||||
|
||||
private Session toSession(String origin, String space, String jwt, JsonObject htxx) {
|
||||
String apiBase = htxx.getString("qqdz");
|
||||
if (apiBase != null && !apiBase.endsWith("/")) {
|
||||
apiBase = apiBase + "/";
|
||||
}
|
||||
String dlmc = htxx.getString("dlmc", space);
|
||||
return new Session(origin, dlmc, jwt, apiBase);
|
||||
}
|
||||
|
||||
private Future<JsonArray> fetchDirectories(Session session) {
|
||||
Promise<JsonArray> p = Promise.promise();
|
||||
apiPost(session, "ml/mldq", new JsonObject())
|
||||
.onSuccess(json -> {
|
||||
JsonArray lb = json.getJsonArray("lb");
|
||||
p.complete(lb != null ? lb : new JsonArray());
|
||||
})
|
||||
.onFailure(p::fail);
|
||||
return p.future();
|
||||
}
|
||||
|
||||
private Future<JsonObject> fetchFiles(Session session, int dirId) {
|
||||
Promise<JsonObject> p = Promise.promise();
|
||||
JsonObject body = new JsonObject()
|
||||
.put("mlbh", dirId)
|
||||
.put("kqmm", "")
|
||||
.put("wjbh", 0)
|
||||
.put("ip1", "");
|
||||
apiPost(session, "wj/wjdq", body)
|
||||
.onSuccess(p::complete)
|
||||
.onFailure(p::fail);
|
||||
return p.future();
|
||||
}
|
||||
|
||||
private Future<JsonObject> apiPost(Session session, String path, JsonObject body) {
|
||||
Promise<JsonObject> p = Promise.promise();
|
||||
String url = session.apiBase + path;
|
||||
HttpRequest<Buffer> req = clientSession.postAbs(url)
|
||||
.putHeader("User-Agent", BROWSER_UA)
|
||||
.putHeader("Accept", "application/json, text/plain, */*")
|
||||
.putHeader("Content-Type", "application/json")
|
||||
.putHeader("Origin", session.origin)
|
||||
.putHeader("Referer", session.origin + "/")
|
||||
.putHeader("Authorization", "Bearer " + session.jwt);
|
||||
req.sendJsonObject(body)
|
||||
.onSuccess(res -> {
|
||||
try {
|
||||
if (res.statusCode() >= 400) {
|
||||
p.fail(baseMsg() + " - API " + path + " HTTP " + res.statusCode());
|
||||
return;
|
||||
}
|
||||
JsonObject json = asJson(res);
|
||||
if (json == null || json.isEmpty()) {
|
||||
p.fail(baseMsg() + " - API " + path + " 返回空响应");
|
||||
return;
|
||||
}
|
||||
p.complete(json);
|
||||
} catch (Exception e) {
|
||||
p.fail(baseMsg() + " - API " + path + " 响应异常: " + e.getMessage());
|
||||
}
|
||||
})
|
||||
.onFailure(t -> p.fail(baseMsg() + " - API " + path + " 请求失败: " + t.getMessage()));
|
||||
return p.future();
|
||||
}
|
||||
|
||||
private Future<List<CollectedFile>> collectDownloadableFiles(Session session, JsonArray dirs) {
|
||||
Promise<List<CollectedFile>> p = Promise.promise();
|
||||
List<CollectedFile> collected = new ArrayList<>();
|
||||
List<Integer> dirIds = new ArrayList<>();
|
||||
for (int i = 0; i < dirs.size(); i++) {
|
||||
JsonObject dir = dirs.getJsonObject(i);
|
||||
if (isAccessibleDirectory(dir)) {
|
||||
dirIds.add(dir.getInteger("bh"));
|
||||
}
|
||||
}
|
||||
if (dirIds.isEmpty()) {
|
||||
p.complete(collected);
|
||||
return p.future();
|
||||
}
|
||||
|
||||
fetchNextDirFiles(session, dirIds, 0, collected, p);
|
||||
return p.future();
|
||||
}
|
||||
|
||||
private void fetchNextDirFiles(Session session, List<Integer> dirIds, int index,
|
||||
List<CollectedFile> collected, Promise<List<CollectedFile>> promise) {
|
||||
if (index >= dirIds.size()) {
|
||||
promise.complete(collected);
|
||||
return;
|
||||
}
|
||||
int dirId = dirIds.get(index);
|
||||
fetchFiles(session, dirId).onSuccess(filesResp -> {
|
||||
for (JsonObject file : downloadableFiles(filesResp)) {
|
||||
collected.add(new CollectedFile(filesResp, file));
|
||||
}
|
||||
fetchNextDirFiles(session, dirIds, index + 1, collected, promise);
|
||||
}).onFailure(promise::fail);
|
||||
}
|
||||
|
||||
private List<FileInfo> mapDirectories(Session session, JsonArray dirs) {
|
||||
List<FileInfo> result = new ArrayList<>();
|
||||
for (int i = 0; i < dirs.size(); i++) {
|
||||
JsonObject dir = dirs.getJsonObject(i);
|
||||
if (!isAccessibleDirectory(dir)) {
|
||||
continue;
|
||||
}
|
||||
Integer bh = dir.getInteger("bh");
|
||||
if (bh == null) {
|
||||
continue;
|
||||
}
|
||||
String title = StringUtils.defaultIfBlank(dir.getString("bt"), "目录" + bh);
|
||||
FileInfo info = new FileInfo()
|
||||
.setFileName(title)
|
||||
.setFileId(bh.toString())
|
||||
.setFileType("folder")
|
||||
.setSize(0L)
|
||||
.setSizeStr("0B")
|
||||
.setDescription(dir.getString("sm", ""))
|
||||
.setCreateTime(normalizeTime(dir.getString("sj")))
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&pwd=%s",
|
||||
getDomainName(),
|
||||
urlEncode(shareLinkInfo.getShareUrl()),
|
||||
bh,
|
||||
urlEncode(StringUtils.defaultString(shareLinkInfo.getSharePassword()))));
|
||||
result.add(info);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<FileInfo> mapFiles(Session session, int dirId, JsonObject filesResp) {
|
||||
List<FileInfo> result = new ArrayList<>();
|
||||
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz", "");
|
||||
JsonArray lb = filesResp.getJsonArray("lb", new JsonArray());
|
||||
for (int i = 0; i < lb.size(); i++) {
|
||||
JsonObject item = lb.getJsonObject(i);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String wjlx = item.getString("wjlx", "");
|
||||
Integer bh = item.getInteger("bh");
|
||||
if (bh == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// URL / 公告条目
|
||||
if ("url".equalsIgnoreCase(wjlx)) {
|
||||
String title = StringUtils.defaultIfBlank(item.getString("bt"), item.getString("wjm", "链接"));
|
||||
String link = item.getString("wjm", "");
|
||||
FileInfo urlInfo = new FileInfo()
|
||||
.setFileName(title)
|
||||
.setFileId(bh.toString())
|
||||
.setFileType("url")
|
||||
.setSize(0L)
|
||||
.setSizeStr("0B")
|
||||
.setFilePath(item.getString("zml", ""))
|
||||
.setCreateTime(normalizeTime(item.getString("sj")))
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
.setPreviewUrl(link)
|
||||
.setDescription(link);
|
||||
result.add(urlInfo);
|
||||
continue;
|
||||
}
|
||||
|
||||
String fileName = item.getString("wjm");
|
||||
String fwq = item.getString("fwq");
|
||||
String pz = item.getString("pz");
|
||||
if (StringUtils.isAnyBlank(fileName, fwq, pz, xzpz)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
long size = item.getLong("dx", 0L);
|
||||
String downloadUrl = buildDownloadUrl(session.space, xzpz, pz, fwq, fileName, true);
|
||||
JsonObject param = new JsonObject()
|
||||
.put("space", session.space)
|
||||
.put("xzpz", xzpz)
|
||||
.put("pz", pz)
|
||||
.put("fwq", fwq)
|
||||
.put("fileName", fileName)
|
||||
.put("mlbh", dirId)
|
||||
.put("fileId", bh)
|
||||
.put("downloadUrl", downloadUrl)
|
||||
.put("referer", session.origin + "/");
|
||||
String paramEncoded = CommonUtils.urlBase64Encode(param.encode());
|
||||
|
||||
FileInfo fileInfo = new FileInfo()
|
||||
.setFileName(fileName)
|
||||
.setFileId(bh.toString())
|
||||
.setFileType("file")
|
||||
.setSize(size)
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(size))
|
||||
.setFilePath(item.getString("zml", ""))
|
||||
.setCreateTime(normalizeTime(item.getString("sj")))
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
|
||||
getDomainName(), shareLinkInfo.getType(), paramEncoded))
|
||||
.setPreviewUrl(String.format("%s/v2/viewUrl/%s/%s",
|
||||
getDomainName(), shareLinkInfo.getType(), paramEncoded));
|
||||
result.add(fileInfo);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<JsonObject> downloadableFiles(JsonObject filesResp) {
|
||||
List<JsonObject> files = new ArrayList<>();
|
||||
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz", "");
|
||||
if (StringUtils.isBlank(xzpz)) {
|
||||
return files;
|
||||
}
|
||||
JsonArray lb = filesResp.getJsonArray("lb", new JsonArray());
|
||||
for (int i = 0; i < lb.size(); i++) {
|
||||
JsonObject item = lb.getJsonObject(i);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if ("url".equalsIgnoreCase(item.getString("wjlx", ""))) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isAnyBlank(item.getString("wjm"), item.getString("fwq"), item.getString("pz"))) {
|
||||
continue;
|
||||
}
|
||||
files.add(item);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private void completeDownload(Session session, JsonObject filesResp, JsonObject file) {
|
||||
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz");
|
||||
String url = buildDownloadUrl(session.space, xzpz, file.getString("pz"),
|
||||
file.getString("fwq"), file.getString("wjm"), true);
|
||||
|
||||
FileInfo fileInfo = new FileInfo()
|
||||
.setFileName(file.getString("wjm"))
|
||||
.setFileId(String.valueOf(file.getInteger("bh")))
|
||||
.setSize(file.getLong("dx", 0L))
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(file.getLong("dx", 0L)))
|
||||
.setFileType("file")
|
||||
.setFilePath(file.getString("zml", ""))
|
||||
.setCreateTime(normalizeTime(file.getString("sj")))
|
||||
.setPanType(shareLinkInfo.getType());
|
||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
||||
completeWithMeta(url, downloadHeaders(session.origin + "/"));
|
||||
}
|
||||
|
||||
static String buildDownloadUrl(String space, String xzpz, String pz, String fwq,
|
||||
String fileName, boolean forceDownload) {
|
||||
String token = forceDownload ? "_" + xzpz : xzpz;
|
||||
String host = "X".equalsIgnoreCase(fwq)
|
||||
? "y.ys168.com:8000"
|
||||
: "ys-" + fwq.toLowerCase() + ".ysepan.com";
|
||||
return "https://" + host + "/wap/"
|
||||
+ encodePathSegment(space) + "/"
|
||||
+ encodePathSegment(token) + "/"
|
||||
+ encodePathSegment(pz) + "/"
|
||||
+ encodePathSegment(fileName);
|
||||
}
|
||||
|
||||
private static String encodePathSegment(String value) {
|
||||
// 保留永硕 token 中常见的 . , _ - 等字符,仅编码必须转义的部分
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8)
|
||||
.replace("+", "%20")
|
||||
.replace("%2E", ".")
|
||||
.replace("%2C", ",")
|
||||
.replace("%5F", "_")
|
||||
.replace("%2D", "-");
|
||||
}
|
||||
|
||||
private Map<String, String> downloadHeaders(String referer) {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("User-Agent", BROWSER_UA);
|
||||
if (StringUtils.isNotBlank(referer)) {
|
||||
headers.put("Referer", referer);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
private boolean isAccessibleDirectory(JsonObject dir) {
|
||||
if (dir == null) {
|
||||
return false;
|
||||
}
|
||||
// 无可下载权限或需特殊打开方式的目录忽略
|
||||
if (!Boolean.TRUE.equals(dir.getBoolean("qxz", true))) {
|
||||
return false;
|
||||
}
|
||||
Integer kqfs = dir.getInteger("kqfs", 0);
|
||||
return kqfs == null || kqfs == 0;
|
||||
}
|
||||
|
||||
private String spaceName() {
|
||||
String key = shareLinkInfo.getShareKey();
|
||||
if (StringUtils.isBlank(key)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return java.net.URLDecoder.decode(key, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
private String spaceOrigin() {
|
||||
String shareUrl = shareLinkInfo.getShareUrl();
|
||||
if (StringUtils.isNotBlank(shareUrl)) {
|
||||
try {
|
||||
java.net.URI uri = java.net.URI.create(shareUrl);
|
||||
String scheme = uri.getScheme() != null ? uri.getScheme() : "https";
|
||||
return scheme + "://" + uri.getHost();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
return "https://" + spaceName() + ".ysepan.com";
|
||||
}
|
||||
|
||||
private static String extractAntiforgery(String html) {
|
||||
Matcher m = ANTIFORGERY_PATTERN.matcher(html);
|
||||
return m.find() ? m.group(1) : null;
|
||||
}
|
||||
|
||||
private static JsonObject extractHtxx(String html) {
|
||||
Matcher m = HTXX_PATTERN.matcher(html);
|
||||
if (!m.find()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new JsonObject(m.group(1));
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractJwt(HttpResponse<Buffer> res) {
|
||||
List<String> setCookies = res.cookies();
|
||||
if (setCookies != null) {
|
||||
for (String c : setCookies) {
|
||||
Matcher m = JWT_COOKIE_PATTERN.matcher(c);
|
||||
if (m.find()) {
|
||||
return m.group(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
String raw = res.getHeader("Set-Cookie");
|
||||
if (raw != null) {
|
||||
Matcher m = JWT_COOKIE_PATTERN.matcher(raw);
|
||||
if (m.find()) {
|
||||
return m.group(1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String normalizeTime(String sj) {
|
||||
if (StringUtils.isBlank(sj)) {
|
||||
return sj;
|
||||
}
|
||||
// 2024-10-26T10:12:54.98 -> 2024-10-26 10:12:54
|
||||
String t = sj.replace('T', ' ');
|
||||
int dot = t.indexOf('.');
|
||||
if (dot > 0) {
|
||||
t = t.substring(0, dot);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
private static String urlEncode(String value) {
|
||||
return URLEncoder.encode(StringUtils.defaultString(value), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private record Session(String origin, String space, String jwt, String apiBase) {
|
||||
}
|
||||
|
||||
private record CollectedFile(JsonObject filesResp, JsonObject file) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package cn.qaiu.util;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 123分享链接子域工具:将分享 key 的前半段解码为数字 uid。
|
||||
*/
|
||||
public final class YeShareHostUtil {
|
||||
|
||||
private static final String CODE62 = "Tvd3hHA9QEkom14xpfaBJIMwgFYGPXn2sWCNORDr80KuUSl7bZcetizL5q6yVj";
|
||||
private static final long MAX_SAFE_INTEGER = 9007199254740991L;
|
||||
private static final Map<Character, Integer> DECODE_MAP = new HashMap<>();
|
||||
|
||||
static {
|
||||
for (int i = 0; i < CODE62.length(); i++) {
|
||||
DECODE_MAP.put(CODE62.charAt(i), i);
|
||||
}
|
||||
}
|
||||
|
||||
private YeShareHostUtil() {
|
||||
}
|
||||
|
||||
public static String getNumericSubdomainIdByShareKey(String shareKey) {
|
||||
String normalized = normalizeShareKey(shareKey);
|
||||
if (StringUtils.isBlank(normalized)) {
|
||||
return "";
|
||||
}
|
||||
int split = normalized.indexOf('-');
|
||||
if (split <= 0) {
|
||||
return "";
|
||||
}
|
||||
String encodedUid = normalized.substring(0, split);
|
||||
Long uid = decodeBase62LittleEndian(encodedUid);
|
||||
return uid == null ? "" : String.valueOf(uid);
|
||||
}
|
||||
|
||||
public static String normalizeShareKey(String shareKey) {
|
||||
if (StringUtils.isBlank(shareKey)) {
|
||||
return "";
|
||||
}
|
||||
String key = shareKey.trim();
|
||||
int queryIndex = key.indexOf('?');
|
||||
if (queryIndex >= 0) {
|
||||
key = key.substring(0, queryIndex);
|
||||
}
|
||||
int slashIndex = key.lastIndexOf('/');
|
||||
if (slashIndex >= 0 && slashIndex < key.length() - 1) {
|
||||
key = key.substring(slashIndex + 1);
|
||||
}
|
||||
if (key.endsWith(".html")) {
|
||||
key = key.substring(0, key.length() - 5);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
private static Long decodeBase62LittleEndian(String value) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
long result = 0L;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
Integer digit = DECODE_MAP.get(value.charAt(i));
|
||||
if (digit == null) {
|
||||
return null;
|
||||
}
|
||||
double weighted = digit * Math.pow(62, i);
|
||||
if (!Double.isFinite(weighted)) {
|
||||
return null;
|
||||
}
|
||||
long next = result + (long) weighted;
|
||||
if (next <= 0 || next > MAX_SAFE_INTEGER) {
|
||||
return null;
|
||||
}
|
||||
result = next;
|
||||
}
|
||||
if (result <= 0) {
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.qaiu.parser;
|
||||
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.util.YeShareHostUtil;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
@@ -157,6 +158,26 @@ public class PanDomainTemplateTest {
|
||||
assertEquals("somekey", m5.group("KEY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testYeRedirectPatternAndUidDecode() {
|
||||
Pattern yePattern = PanDomainTemplate.YE.getPattern();
|
||||
|
||||
Matcher oldUrl = yePattern.matcher("https://www.123pan.com/s/lN7UVv-pbYJ");
|
||||
assertTrue("YE should match old 123pan share URL", oldUrl.find());
|
||||
assertEquals("lN7UVv-pbYJ", oldUrl.group("KEY"));
|
||||
|
||||
Matcher redirectedUrl = yePattern.matcher("https://1813382308.mshare.123pan.cn/123pan/lN7UVv-pbYJ");
|
||||
assertTrue("YE should match redirected mshare URL", redirectedUrl.find());
|
||||
assertEquals("lN7UVv-pbYJ", redirectedUrl.group("KEY"));
|
||||
|
||||
Matcher htmlUrl = yePattern.matcher("https://www.123278.com/s/lN7UVv-pbYJ.html?pwd=abcd");
|
||||
assertTrue("YE should match html URL with query", htmlUrl.find());
|
||||
assertEquals("lN7UVv-pbYJ", htmlUrl.group("KEY"));
|
||||
|
||||
assertEquals("1813382308", YeShareHostUtil.getNumericSubdomainIdByShareKey("lN7UVv-pbYJ"));
|
||||
assertEquals("lN7UVv-pbYJ", YeShareHostUtil.normalizeShareKey("https://1813382308.mshare.123pan.cn/123pan/lN7UVv-pbYJ?pwd=abcd"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLePatternFix() {
|
||||
Pattern lePattern = PanDomainTemplate.LE.getPattern();
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package cn.qaiu.parser.impl;
|
||||
|
||||
import cn.qaiu.WebClientVertxInit;
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.parser.PanDomainTemplate;
|
||||
import cn.qaiu.parser.ParserCreate;
|
||||
import cn.qaiu.util.CommonUtils;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* 永硕E盘解析测试(含示例空间联调)
|
||||
*/
|
||||
public class YsToolTest {
|
||||
|
||||
private static Vertx vertx;
|
||||
|
||||
@BeforeClass
|
||||
public static void setUpClass() {
|
||||
vertx = Vertx.vertx();
|
||||
WebClientVertxInit.init(vertx);
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void tearDownClass() {
|
||||
if (vertx != null) {
|
||||
vertx.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPatternMatching() {
|
||||
Pattern pattern = PanDomainTemplate.YS.getPattern();
|
||||
|
||||
Matcher m1 = pattern.matcher("https://qaiu.ysepan.com/");
|
||||
assertTrue(m1.matches());
|
||||
assertEquals("qaiu", m1.group("KEY"));
|
||||
|
||||
Matcher m2 = pattern.matcher("http://sohehe4.ysepan.com");
|
||||
assertTrue(m2.matches());
|
||||
assertEquals("sohehe4", m2.group("KEY"));
|
||||
|
||||
Matcher m3 = pattern.matcher("https://demo.ys168.com/");
|
||||
assertTrue(m3.matches());
|
||||
assertEquals("demo", m3.group("KEY"));
|
||||
|
||||
assertFalse(pattern.matcher("https://www.ysepan.com/").matches());
|
||||
assertFalse(pattern.matcher("https://c6.ysepan.com/api/ml/mldq").matches());
|
||||
assertFalse(pattern.matcher("https://ys-c.ysepan.com/wap/qaiu/x/y/z").matches());
|
||||
assertFalse(pattern.matcher("https://zy.ysepan.com/assets/index.js").matches());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testIdentifyShareUrl() {
|
||||
ParserCreate create = ParserCreate.fromShareUrl("https://qaiu.ysepan.com/");
|
||||
assertEquals("ys", create.getShareLinkInfo().getType());
|
||||
assertEquals("永硕E盘", create.getShareLinkInfo().getPanName());
|
||||
assertEquals("qaiu", create.getShareLinkInfo().getShareKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBuildDownloadUrl() {
|
||||
String url = YsTool.buildDownloadUrl(
|
||||
"qaiu",
|
||||
"UOkGHeA9O9hFJHG",
|
||||
"rEBaljD.Ba69AMzTBmAb9AC9CPvC2E",
|
||||
"C",
|
||||
"Pycharm2023.1激活.zip",
|
||||
true);
|
||||
assertEquals(
|
||||
"https://ys-c.ysepan.com/wap/qaiu/_UOkGHeA9O9hFJHG/rEBaljD.Ba69AMzTBmAb9AC9CPvC2E/Pycharm2023.1%E6%BF%80%E6%B4%BB.zip",
|
||||
url);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testQaiuSpaceFileListAndDownload() throws Exception {
|
||||
ParserCreate create = ParserCreate.fromShareUrl("https://qaiu.ysepan.com/");
|
||||
create.getShareLinkInfo().setSharePassword("qaiuys168");
|
||||
create.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
|
||||
|
||||
List<FileInfo> dirs = create.createTool().parseFileList()
|
||||
.toCompletionStage().toCompletableFuture()
|
||||
.get(60, TimeUnit.SECONDS);
|
||||
|
||||
assertNotNull(dirs);
|
||||
assertFalse("目录列表不应为空", dirs.isEmpty());
|
||||
assertEquals("folder", dirs.get(0).getFileType());
|
||||
String dirId = dirs.get(0).getFileId();
|
||||
assertNotNull(dirId);
|
||||
|
||||
ParserCreate filesCreate = ParserCreate.fromShareUrl("https://qaiu.ysepan.com/");
|
||||
filesCreate.getShareLinkInfo().setSharePassword("qaiuys168");
|
||||
filesCreate.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
|
||||
filesCreate.getShareLinkInfo().getOtherParam().put("dirId", dirId);
|
||||
|
||||
List<FileInfo> files = filesCreate.createTool().parseFileList()
|
||||
.toCompletionStage().toCompletableFuture()
|
||||
.get(60, TimeUnit.SECONDS);
|
||||
|
||||
assertNotNull(files);
|
||||
FileInfo zip = files.stream()
|
||||
.filter(f -> "file".equals(f.getFileType()))
|
||||
.filter(f -> f.getFileName() != null && f.getFileName().contains("Pycharm"))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
assertNotNull("应能列出 Pycharm 压缩包", zip);
|
||||
assertTrue(zip.getSize() > 0);
|
||||
assertNotNull(zip.getParserUrl());
|
||||
|
||||
String param = zip.getParserUrl().substring(zip.getParserUrl().lastIndexOf('/') + 1);
|
||||
String decoded = CommonUtils.urlBase64Decode(param);
|
||||
JsonObject paramJson = new JsonObject(decoded);
|
||||
|
||||
ParserCreate byId = ParserCreate.fromType("ys").shareKey("qaiu");
|
||||
byId.getShareLinkInfo().setSharePassword("qaiuys168");
|
||||
byId.getShareLinkInfo().getOtherParam().put("paramJson", paramJson);
|
||||
|
||||
String downloadUrl = byId.createTool().parseById()
|
||||
.toCompletionStage().toCompletableFuture()
|
||||
.get(60, TimeUnit.SECONDS);
|
||||
|
||||
assertNotNull(downloadUrl);
|
||||
assertTrue(downloadUrl.contains("ys-c.ysepan.com") || downloadUrl.contains("ysepan.com"));
|
||||
assertTrue(downloadUrl.contains("Pycharm") || downloadUrl.contains("%E6%BF%80%E6%B4%BB"));
|
||||
System.out.println("qaiu downloadUrl=" + downloadUrl);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSohehe4SpaceDirectories() throws Exception {
|
||||
ParserCreate create = ParserCreate.fromShareUrl("https://sohehe4.ysepan.com/");
|
||||
create.getShareLinkInfo().setSharePassword("1234");
|
||||
create.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
|
||||
|
||||
List<FileInfo> dirs = create.createTool().parseFileList()
|
||||
.toCompletionStage().toCompletableFuture()
|
||||
.get(60, TimeUnit.SECONDS);
|
||||
|
||||
assertNotNull(dirs);
|
||||
assertTrue("sohehe4 应有多个目录", dirs.size() >= 3);
|
||||
assertTrue(dirs.stream().allMatch(d -> "folder".equals(d.getFileType())));
|
||||
|
||||
String dirId = dirs.stream()
|
||||
.filter(d -> d.getFileName() != null && d.getFileName().contains("留言"))
|
||||
.map(FileInfo::getFileId)
|
||||
.findFirst()
|
||||
.orElse(dirs.get(0).getFileId());
|
||||
|
||||
ParserCreate filesCreate = ParserCreate.fromShareUrl("https://sohehe4.ysepan.com/");
|
||||
filesCreate.getShareLinkInfo().setSharePassword("1234");
|
||||
filesCreate.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
|
||||
filesCreate.getShareLinkInfo().getOtherParam().put("dirId", dirId);
|
||||
|
||||
List<FileInfo> files = filesCreate.createTool().parseFileList()
|
||||
.toCompletionStage().toCompletableFuture()
|
||||
.get(60, TimeUnit.SECONDS);
|
||||
|
||||
assertNotNull(files);
|
||||
assertFalse(files.isEmpty());
|
||||
assertTrue("应包含文件或URL条目",
|
||||
files.stream().anyMatch(f -> "file".equals(f.getFileType()) || "url".equals(f.getFileType())));
|
||||
System.out.println("sohehe4 dir=" + dirId + " entries=" + files.size());
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<revision>0.3.4</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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nfd-web",
|
||||
"version": "0.2.1",
|
||||
"version": "0.4.1",
|
||||
"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)
|
||||
|
||||
@@ -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("认证");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 auth) {
|
||||
String url = URLParamUtil.parserParams(request);
|
||||
ParserCreate parserCreate;
|
||||
try {
|
||||
@@ -168,6 +170,7 @@ 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)) {
|
||||
@@ -176,13 +179,22 @@ public class ParserApi {
|
||||
if (StringUtils.isNotBlank(uuid)) {
|
||||
parserCreate.getShareLinkInfo().getOtherParam().put("uuid", uuid);
|
||||
}
|
||||
IPanTool tool = parserCreate.createTool();
|
||||
return IPanTool.closeAfter(tool, tool::parseFileList);
|
||||
return ParserAuthUtil.applyAuthParamsAndDonatedFallback(parserCreate, otherParam, dbService)
|
||||
.compose(v -> {
|
||||
URLParamUtil.addParam(parserCreate);
|
||||
IPanTool tool = parserCreate.createTool();
|
||||
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 +210,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);
|
||||
IPanTool tool = parserCreate.createTool();
|
||||
return IPanTool.closeAfter(tool, tool::parseById);
|
||||
return ParserAuthUtil.applyAuthParamsAndDonatedFallback(parserCreate, otherParam, dbService)
|
||||
.compose(v -> {
|
||||
URLParamUtil.addParam(parserCreate);
|
||||
IPanTool tool = parserCreate.createTool();
|
||||
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 +306,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"));
|
||||
|
||||
Reference in New Issue
Block a user