mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-08-26 11:32:02 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bd9830e04 | |||
| 2aeb64b87c |
+29
-34
@@ -8,7 +8,7 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '*'
|
||||
branches-ignore:
|
||||
- '*'
|
||||
paths-ignore:
|
||||
@@ -158,19 +158,37 @@ jobs:
|
||||
path: web-service/target/package
|
||||
|
||||
# ============================================================
|
||||
# 选择 JDK 模块
|
||||
# jdeps 分析 → 确定所需 JDK 模块
|
||||
# ============================================================
|
||||
- name: 选择完整 JDK 模块集合
|
||||
- name: 分析所需 JDK 模块(jdeps)
|
||||
run: |
|
||||
# 123 网盘/自定义 JS 解析器会通过 javax.script、Nashorn 和 SPI/反射加载模块,
|
||||
# jdeps 无法稳定识别这些运行时依赖。这里优先保证原生包可运行,使用完整
|
||||
# JDK 模块集合生成运行时,避免 Windows 精简 JRE 缺 java.scripting/jdk.dynalink 等模块。
|
||||
printf 'JDK_MODULES=ALL-MODULE-PATH\n' >> $GITHUB_ENV
|
||||
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
|
||||
|
||||
# ============================================================
|
||||
# jlink 生成完整模块 JRE
|
||||
# jlink 生成精简 JRE
|
||||
# ============================================================
|
||||
- name: 生成完整模块 JRE(jlink)
|
||||
- name: 生成精简 JRE(jlink)
|
||||
run: |
|
||||
jlink \
|
||||
--module-path "$JAVA_HOME/jmods" \
|
||||
@@ -197,11 +215,6 @@ 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@'
|
||||
|
||||
# ============================================================
|
||||
# 组装包目录
|
||||
# ============================================================
|
||||
@@ -262,27 +275,9 @@ jobs:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: ${{ matrix.artifact-name }}.zip
|
||||
|
||||
# ================================================================
|
||||
# 阶段三:创建 GitHub Release(只跑一次,避免矩阵任务重复拼接更新日志)
|
||||
# ================================================================
|
||||
publish-release:
|
||||
name: 发布 GitHub Release
|
||||
needs: native-package
|
||||
if: github.event_name != 'pull_request' && github.ref_type == 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 下载原生安装包
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: netdisk-fast-download-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: 创建 Release
|
||||
- name: 上传到 Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
netdisk-fast-download-linux-amd64.zip
|
||||
netdisk-fast-download-windows-amd64.zip
|
||||
files: ${{ matrix.artifact-name }}.zip
|
||||
tag_name: ${{ github.ref_name }}
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
@@ -6,10 +6,8 @@ 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>
|
||||
@@ -20,7 +18,7 @@ QQ交流群:1017480890
|
||||
|
||||
|
||||
## 介绍
|
||||
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/永硕E盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
||||
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
||||
|
||||
[官方文档](https://nfd-parser.github.io/)
|
||||
[API接入](https://nfdparser.apifox.cn/)
|
||||
@@ -28,30 +26,24 @@ QQ交流群:1017480890
|
||||
[公益解析,lz0站](https://lz0.qaiu.top)
|
||||
[专业版](https://189.qaiu.top)
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
以带提取码的分享链接为例(提取码 `1234`),链接中含 `#`、`&`,作为 `url` 参数传入前必须做 URL 编码:
|
||||
|
||||
`https://www.ecpan.cn/web/#/yunpanProxy?path=%2F%23%2Fdrive%2Foutside&data=70017ece572737b12b30709b7f029308eJD0&isShare=1`
|
||||
|
||||
**命令行下载**
|
||||
|
||||
命令行下载分享文件:
|
||||
```shell
|
||||
# curl
|
||||
curl -LOJ "https://lz.qaiu.top/parser?url=https%3A%2F%2Fwww.ecpan.cn%2Fweb%2F%23%2FyunpanProxy%3Fpath%3D%252F%2523%252Fdrive%252Foutside%26data%3D70017ece572737b12b30709b7f029308eJD0%26isShare%3D1&pwd=1234"
|
||||
|
||||
# wget
|
||||
wget -O v01 "https://lz.qaiu.top/parser?url=https%3A%2F%2Fwww.ecpan.cn%2Fweb%2F%23%2FyunpanProxy%3Fpath%3D%252F%2523%252Fdrive%252Foutside%26data%3D70017ece572737b12b30709b7f029308eJD0%26isShare%3D1&pwd=1234"
|
||||
curl -LOJ "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/Tk1F2kGQ&pwd=1234"
|
||||
```
|
||||
或者使用wget:
|
||||
```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%2Fwww.ilanzou.com%2Fs%2FCDx6xKbT&name=bilibili.mp4&ext=mp4):
|
||||
```
|
||||
### 调用演示站下载:
|
||||
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%2Fwww.ilanzou.com%2Fs%2FCDx6xKbT&name=bilibili.mp4&ext=mp4
|
||||
|
||||
**短链方式**(key 取分享链接的 `data` 参数,提取码用 `@` 拼接)
|
||||
|
||||
| 用途 | 地址 |
|
||||
| --- | --- |
|
||||
| 下载 | `https://lz.qaiu.top/ec/70017ece572737b12b30709b7f029308eJD0@1234` |
|
||||
| 在线预览 | [点击访问](https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fec%2F70017ece572737b12b30709b7f029308eJD0%401234&name=v01&ext=mp4) |
|
||||
|
||||
> 无提取码时去掉 `&pwd=1234` / `@1234` 即可;预览页 `src` 需编码(`@` → `%40`),用短链可避免二次编码。
|
||||
```
|
||||
|
||||
**解析器模块文档:** [parser/README.md](parser/README.md)
|
||||
|
||||
@@ -75,14 +67,13 @@ wget -O v01 "https://lz.qaiu.top/parser?url=https%3A%2F%2Fwww.ecpan.cn%2Fweb%2F%
|
||||
- [移动云云空间-ec](https://www.ecpan.cn/web)
|
||||
- [小飞机网盘-fj](https://www.feijipan.com/)
|
||||
- [亿方云-fc](https://www.fangcloud.com/)
|
||||
- [123云盘-ye](https://www.123pan.com/) ⚠️仅建议本地部署使用,需登录认证,公共/云端服务器风控严格建议自行部署(Windows 可直接用 run.bat 一键运行)
|
||||
- [123云盘-ye](https://www.123pan.com/)
|
||||
- ~[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)
|
||||
@@ -139,7 +130,6 @@ GET /json/parser?url={分享链接}&pwd={密码}
|
||||
```
|
||||
GET /json/{网盘标识}/{分享key}@{密码}
|
||||
```
|
||||
注意: 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值
|
||||
|
||||
#### 3. 文件夹解析(v0.1.8fixed3+)
|
||||
|
||||
@@ -184,7 +174,6 @@ GET /json/getFileList?url={分享链接}&pwd={密码}
|
||||
| UC网盘(UC) | **必须** | 必须配置 Cookie 才能解析 |
|
||||
| 小飞机网盘(FJ) | 可选 | 大文件(>100MB)需要认证 |
|
||||
| 蓝奏优享(IZ) | 可选 | 大文件需要认证 |
|
||||
| 123网盘(YE) | 可选 | 需要下载大文件/需要登录的分享时才需要认证,支持账号密码或 token/authorization |
|
||||
|
||||
**使用示例**:
|
||||
```
|
||||
@@ -205,37 +194,10 @@ 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` 参数值
|
||||
- 移动云云空间、小飞机网盘的加密分享可忽略密码参数
|
||||
|
||||
### 示例
|
||||
|
||||
@@ -312,12 +274,9 @@ json返回数据格式示例:
|
||||
"timestamp": 1736489219402
|
||||
}
|
||||
```
|
||||
#### 3. 文件夹解析(支持蓝奏云/蓝奏优享/小飞机/永硕E盘等)
|
||||
#### 3. 文件夹解析(仅支持蓝奏云/蓝奏优享/小飞机网盘)
|
||||
/v2/getFileList?url=分享链接&pwd=分享密码
|
||||
|
||||
永硕E盘(`ys`)空间链接先返回目录列表,再带 `dirId` 获取目录内文件:
|
||||
`/v2/getFileList?url=https://xxx.ysepan.com/&pwd=空间密码&dirId=目录编号`
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
@@ -373,7 +332,6 @@ json返回数据格式示例:
|
||||
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
|
||||
| 123云盘 | √ | √ | 2T | 100G(>100M需要登录) |
|
||||
| 文叔叔 | √ | √ | 10G | 5GB |
|
||||
| 永硕E盘 | √ | √(空间密码) | 视套餐 | 视套餐 |
|
||||
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
|
||||
| 夸克网盘 | x | √ | 10G | 不限大小 |
|
||||
| UC网盘 | x | √ | 10G | 不限大小 |
|
||||
@@ -457,8 +415,8 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtow
|
||||
> 注意: netdisk-fast-download.service中的ExecStart的路径改为实际路径
|
||||
```shell
|
||||
cd ~
|
||||
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v0.4.5/netdisk-fast-download-linux-amd64.zip
|
||||
unzip netdisk-fast-download.zip
|
||||
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v3.0.2/netdisk-fast-download-bin.zip
|
||||
unzip netdisk-fast-download-bin.zip
|
||||
cd netdisk-fast-download
|
||||
bash service-install.sh
|
||||
```
|
||||
@@ -538,30 +496,13 @@ Core模块集成Vert.x实现类似spring的注解式路由API
|
||||
|
||||
## Star History
|
||||
|
||||
<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>
|
||||
[](https://star-history.com/#qaiu/netdisk-fast-download&Date)
|
||||
|
||||
## **免责声明**
|
||||
- 用户在使用本项目时,应自行承担风险,并确保其行为符合当地法律法规。开发者不对用户因使用本项目而导致的任何后果负责。
|
||||
|
||||
## **声明**
|
||||
|
||||
- 本项目**仅供个人学习与技术交流使用**,请勿用于商业用途或大规模滥用。
|
||||
- 所用接口均来自各网盘**官方开放平台**、官方公开接口及**已知开源项目**,仅解析用户主动提供的分享链接,**不涉及破解或绕过版权保护**。
|
||||
- 本项目**相当于自动化程序代替用户发起请求**,**不会提升或绕过任何会员权限**,也**不会突破网盘的限速策略**;我们**鼓励用户开通官方 VIP/SVIP**以获得更好的下载与加速体验。
|
||||
- 使用者应遵守各网盘服务商的**用户协议与使用条款**,以及所在地区的**法律法规**,违反所产生的后果自行承担。
|
||||
- **高频调用可能触发网盘风控**,导致**账号限流、封禁**或**来源 IP 被屏蔽**,请合理控制请求频率,避免高并发与批量请求。
|
||||
- 网盘接口调整、风控策略变更等因素可能导致功能失效,本项目**不对可用性与稳定性作任何担保**。
|
||||
- 开发者**不对**使用本项目产生的**任何直接或间接损失**(账号封禁、数据丢失、法律纠纷等)**承担责任**。
|
||||
- 开发者保留**随时修改本免责声明**的权利,恕不另行通知。
|
||||
- **下载、部署或使用本项目,即视为您已阅读并接受上述全部条款。**
|
||||
|
||||
|
||||
## 赞助该项目
|
||||
开源不易,用爱发电,本项目长期维护如果觉得有帮助, 可以请开发者[喝杯咖啡](https://blog.qaiu.top/archives/da-shang-zhuan-yong), 感谢支持。
|
||||
## 支持该项目
|
||||
开源不易,用爱发电,本项目长期维护如果觉得有帮助, 可以请作者喝杯咖啡, 感谢支持
|
||||
|
||||
本项目的服务器由林枫云提供赞助<br>
|
||||
</a>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.13</version>
|
||||
<version>42.7.11</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
@@ -162,18 +162,13 @@ public class CommonUtil {
|
||||
try (var is = CommonUtil.class.getClassLoader().getResourceAsStream("app.properties")) {
|
||||
if (is != null) {
|
||||
properties.load(is);
|
||||
String version = properties.getProperty("app.version");
|
||||
String build = properties.getProperty("build");
|
||||
if (version != null && !version.contains("${")) {
|
||||
appVersion = version + "build" + (build == null || build.contains("${") ? "" : build);
|
||||
if (!properties.isEmpty()) {
|
||||
appVersion = properties.getProperty("app.version") + "build" + properties.getProperty("build");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
} catch (IOException e) {
|
||||
LOGGER.error("读取app.properties失败", e);
|
||||
}
|
||||
if (appVersion == null) {
|
||||
appVersion = "unknown";
|
||||
}
|
||||
}
|
||||
return appVersion;
|
||||
}
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@
|
||||
<lombok.version>1.18.38</lombok.version>
|
||||
<slf4j.version>2.0.16</slf4j.version>
|
||||
<commons-lang3.version>3.18.0</commons-lang3.version>
|
||||
<jackson.version>2.18.9</jackson.version>
|
||||
<jackson.version>2.18.6</jackson.version>
|
||||
<logback.version>1.5.32</logback.version>
|
||||
<junit.version>4.13.2</junit.version>
|
||||
</properties>
|
||||
|
||||
@@ -20,9 +20,6 @@ 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;
|
||||
@@ -204,76 +201,6 @@ 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();
|
||||
@@ -368,12 +295,9 @@ public abstract class PanBase implements IPanTool, Closeable {
|
||||
// 检查响应头中的Content-Encoding是否为gzip
|
||||
String contentEncoding = res.getHeader("Content-Encoding");
|
||||
try {
|
||||
if ("gzip".equalsIgnoreCase(contentEncoding) && res.body() instanceof Buffer gzipBody
|
||||
&& gzipBody.length() >= 2
|
||||
&& (gzipBody.getByte(0) & 0xff) == 0x1f
|
||||
&& (gzipBody.getByte(1) & 0xff) == 0x8b) {
|
||||
if ("gzip".equalsIgnoreCase(contentEncoding)) {
|
||||
// 如果是gzip压缩的响应体,解压(只解压一次,缓存结果)
|
||||
String decompressed = decompressGzip(gzipBody);
|
||||
String decompressed = decompressGzip((Buffer) res.body());
|
||||
return new JsonObject(decompressed);
|
||||
} else {
|
||||
return res.bodyAsJsonObject();
|
||||
@@ -385,10 +309,9 @@ public abstract class PanBase implements IPanTool, Closeable {
|
||||
log.error("响应gzip解压或JSON解析失败: {}", e.getMessage());
|
||||
fail("响应gzip解压或JSON解析失败: {}", e.getMessage());
|
||||
} else {
|
||||
// 上游响应体可能来自内网探测目标,仅写日志,避免经 HTTP 500 回传给调用方
|
||||
String bodyPreview = responseBodyPreview(res);
|
||||
log.error("解析失败: json格式异常: {}", bodyPreview);
|
||||
fail("解析失败: json格式异常");
|
||||
fail("解析失败: json格式异常: {}", bodyPreview);
|
||||
}
|
||||
return JsonObject.of();
|
||||
}
|
||||
@@ -418,18 +341,7 @@ public abstract class PanBase implements IPanTool, Closeable {
|
||||
protected void completeWithMeta(String url, Map<String, String> headers) {
|
||||
shareLinkInfo.getOtherParam().put("downloadUrl", url);
|
||||
if (headers != null && !headers.isEmpty()) {
|
||||
// 过滤 null/空值,避免 cookie:null 覆盖入口参数或污染 curl 命令
|
||||
Map<String, String> clean = new HashMap<>();
|
||||
headers.forEach((k, v) -> {
|
||||
if (k != null && v != null && !v.isBlank()) {
|
||||
clean.put(k, v);
|
||||
}
|
||||
});
|
||||
if (!clean.isEmpty()) {
|
||||
shareLinkInfo.getOtherParam().put("downloadHeaders", clean);
|
||||
// UC/夸克等需带 cookie 的直链,标记前端走下载器
|
||||
shareLinkInfo.getOtherParam().put("needDownloader", true);
|
||||
}
|
||||
shareLinkInfo.getOtherParam().put("downloadHeaders", headers);
|
||||
}
|
||||
promise.complete(url);
|
||||
}
|
||||
@@ -537,34 +449,6 @@ public abstract class PanBase implements IPanTool, Closeable {
|
||||
return shareLinkInfo.getOtherParam().getOrDefault("domainName", "").toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将入口请求中的加密 auth 透传到子目录/下载链接,避免进入子目录后丢失认证。
|
||||
* otherParam 中的 key 为 {@code _authQuery}(由 web 层写入)。
|
||||
*/
|
||||
protected String appendAuthQuery(String url) {
|
||||
if (StringUtils.isBlank(url) || shareLinkInfo == null || shareLinkInfo.getOtherParam() == null) {
|
||||
return url;
|
||||
}
|
||||
Object authObj = shareLinkInfo.getOtherParam().get("_authQuery");
|
||||
if (authObj == null) {
|
||||
return url;
|
||||
}
|
||||
String auth = authObj.toString();
|
||||
if (StringUtils.isBlank(auth)) {
|
||||
return url;
|
||||
}
|
||||
// 已带 auth 则不再追加
|
||||
if (url.contains("auth=")) {
|
||||
return url;
|
||||
}
|
||||
try {
|
||||
String encoded = java.net.URLEncoder.encode(auth, StandardCharsets.UTF_8);
|
||||
return url + (url.contains("?") ? "&" : "?") + "auth=" + encoded;
|
||||
} catch (Exception e) {
|
||||
return url + (url.contains("?") ? "&" : "?") + "auth=" + auth;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShareLinkInfo getShareLinkInfo() {
|
||||
return shareLinkInfo;
|
||||
|
||||
@@ -121,9 +121,9 @@ public enum PanDomainTemplate {
|
||||
"https://lecloud.lenovo.com/share/{shareKey}",
|
||||
LeTool.class),
|
||||
|
||||
// https://v2.fangcloud.com/s/ https://v2.fangcloud.cn/h5/share/ (移动端H5落地页)
|
||||
// https://v2.fangcloud.com/s/
|
||||
FC("亿方云",
|
||||
compile("https://v2\\.fangcloud\\.(com|cn)/(?:h5/)?(s|share|sharing)/(?<KEY>.+)"),
|
||||
compile("https://v2\\.fangcloud\\.(com|cn)/(s|share|sharing)/(?<KEY>.+)"),
|
||||
"https://v2.fangcloud.com/s/{shareKey}",
|
||||
"https://www.fangcloud.com/",
|
||||
FcTool.class),
|
||||
@@ -377,16 +377,6 @@ public enum PanDomainTemplate {
|
||||
MmgTool.class),
|
||||
// =====================私有盘解析==========================
|
||||
|
||||
// 永硕E盘空间分享:https://qaiu.ysepan.com/ (空间名即 shareKey,密码为空间访问密码)
|
||||
// 主域名 ysepan.com / ys168.com;备用 cccpan.com / ysupan.com / uupan.net / ysok.net
|
||||
YS("永硕E盘",
|
||||
compile("https?://(?!(?:www|zy|ht|api|c\\d+|ys-[a-zA-Z0-9]+)\\.)(?<KEY>[a-zA-Z\\d-]+)\\."
|
||||
+ "(?:ysepan\\.com|ys168\\.com|cccpan\\.com|ysupan\\.com|uupan\\.net|ysok\\.net)"
|
||||
+ "/?(?:\\?.*)?"),
|
||||
"https://{shareKey}.ysepan.com/",
|
||||
"https://www.ysepan.com/",
|
||||
YsTool.class),
|
||||
|
||||
// Cloudreve自定义域名解析, 解析器CeTool兜底策略, 即任意域名如果匹配不到对应的规则, 则由CeTool统一处理,
|
||||
// 如果不属于Cloudreve盘 则调用下一个自定义域名解析器, 若都处理不了则抛出异常, 这种匹配模式类似责任链
|
||||
// http(s)://pan.huang1111.cn/s/xxx
|
||||
|
||||
@@ -1,149 +1,53 @@
|
||||
package cn.qaiu.parser;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 解析器 Token/Cookie 缓存 — 支持多账号隔离。
|
||||
* <p>
|
||||
* 以 (diskType + "#" + accountKey) 作为缓存 key,不同账号的 token 互不覆盖。
|
||||
* accountKey 优先使用 _configId,其次使用 username、cookie 前16位等可区分标识。
|
||||
* </p>
|
||||
* Parser token cache keyed by parser type and account identity.
|
||||
*/
|
||||
public final class TokenCache {
|
||||
|
||||
private TokenCache() {}
|
||||
private static final Map<String, String> TOKENS = new ConcurrentHashMap<>();
|
||||
private static final Map<String, Long> EXPIRES = new ConcurrentHashMap<>();
|
||||
|
||||
/** token 缓存 */
|
||||
private static final ConcurrentHashMap<String, String> tokenMap = new ConcurrentHashMap<>();
|
||||
/** 过期时间缓存(毫秒时间戳) */
|
||||
private static final ConcurrentHashMap<String, Long> expireMap = new ConcurrentHashMap<>();
|
||||
/** 同一 key 下的额外字符串缓存(如 userId) */
|
||||
private static final ConcurrentHashMap<String, String> extraMap = new ConcurrentHashMap<>();
|
||||
/** 布尔标记缓存(如 authFlag) */
|
||||
private static final ConcurrentHashMap<String, Boolean> flagMap = new ConcurrentHashMap<>();
|
||||
|
||||
// ============ key 构造 ============
|
||||
|
||||
public static String key(String diskType, String accountKey) {
|
||||
return diskType + "#" + (accountKey == null ? "_default" : accountKey);
|
||||
private TokenCache() {
|
||||
}
|
||||
|
||||
// ============ token ============
|
||||
|
||||
public static String getToken(String cacheKey) {
|
||||
return tokenMap.get(cacheKey);
|
||||
public static String key(String type, String accountId) {
|
||||
return type + ":" + (StringUtils.isBlank(accountId) ? "_default" : accountId);
|
||||
}
|
||||
|
||||
public static void putToken(String cacheKey, String token) {
|
||||
if (token == null) {
|
||||
tokenMap.remove(cacheKey);
|
||||
} else {
|
||||
tokenMap.put(cacheKey, token);
|
||||
public static void putToken(String key, String token) {
|
||||
if (StringUtils.isBlank(key) || StringUtils.isBlank(token)) {
|
||||
return;
|
||||
}
|
||||
TOKENS.put(key, token);
|
||||
}
|
||||
|
||||
// ============ expire ============
|
||||
|
||||
public static long getExpire(String cacheKey) {
|
||||
return expireMap.getOrDefault(cacheKey, 0L);
|
||||
}
|
||||
|
||||
public static void putExpire(String cacheKey, long expireMs) {
|
||||
expireMap.put(cacheKey, expireMs);
|
||||
}
|
||||
|
||||
public static boolean isExpired(String cacheKey) {
|
||||
long exp = getExpire(cacheKey);
|
||||
return exp <= 0 || System.currentTimeMillis() > exp;
|
||||
}
|
||||
|
||||
// ============ extra (userId 等) ============
|
||||
|
||||
public static String getExtra(String cacheKey) {
|
||||
return extraMap.get(cacheKey);
|
||||
}
|
||||
|
||||
public static void putExtra(String cacheKey, String value) {
|
||||
if (value == null) {
|
||||
extraMap.remove(cacheKey);
|
||||
} else {
|
||||
extraMap.put(cacheKey, value);
|
||||
public static String getToken(String key) {
|
||||
if (StringUtils.isBlank(key)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ flag (authFlag 等) ============
|
||||
|
||||
public static boolean getFlag(String cacheKey, boolean defaultValue) {
|
||||
return flagMap.getOrDefault(cacheKey, defaultValue);
|
||||
}
|
||||
|
||||
public static void putFlag(String cacheKey, boolean value) {
|
||||
flagMap.put(cacheKey, value);
|
||||
}
|
||||
|
||||
// ============ 清除 ============
|
||||
|
||||
public static void remove(String cacheKey) {
|
||||
tokenMap.remove(cacheKey);
|
||||
expireMap.remove(cacheKey);
|
||||
extraMap.remove(cacheKey);
|
||||
flagMap.remove(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定网盘类型的所有缓存(精准清除,不影响其他网盘类型)
|
||||
*/
|
||||
public static void removeByDiskType(String diskType) {
|
||||
String prefix = diskType + "#";
|
||||
tokenMap.keySet().removeIf(k -> k.startsWith(prefix));
|
||||
expireMap.keySet().removeIf(k -> k.startsWith(prefix));
|
||||
extraMap.keySet().removeIf(k -> k.startsWith(prefix));
|
||||
flagMap.keySet().removeIf(k -> k.startsWith(prefix));
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
tokenMap.clear();
|
||||
expireMap.clear();
|
||||
extraMap.clear();
|
||||
flagMap.clear();
|
||||
}
|
||||
|
||||
// ============ Token 持久化队列 ============
|
||||
|
||||
/** 待持久化的 cachedToken 数据 (cacheKey -> [token, expireMs]) */
|
||||
private static final ConcurrentHashMap<String, String[]> persistQueue = new ConcurrentHashMap<>();
|
||||
/** 待回写的凭据更新 (cacheKey -> newCredential),如 PaliTool refresh_token 轮换 */
|
||||
private static final ConcurrentHashMap<String, String> credentialUpdateQueue = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 解析器登录成功后,将 token 加入持久化队列(下次 recordConfigUsage 回写 DB)
|
||||
*/
|
||||
public static void queueCachedTokenPersist(String cacheKey, String token, long expireMs) {
|
||||
if (cacheKey != null && token != null) {
|
||||
persistQueue.put(cacheKey, new String[]{token, String.valueOf(expireMs)});
|
||||
if (isExpired(key)) {
|
||||
TOKENS.remove(key);
|
||||
EXPIRES.remove(key);
|
||||
return null;
|
||||
}
|
||||
return TOKENS.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭据本身被替换(如 PaliTool refresh_token 轮换),加入回写队列
|
||||
*/
|
||||
public static void queueCredentialUpdate(String cacheKey, String newCredential) {
|
||||
if (cacheKey != null && newCredential != null) {
|
||||
credentialUpdateQueue.put(cacheKey, newCredential);
|
||||
public static void putExpire(String key, long expireTimeMillis) {
|
||||
if (StringUtils.isBlank(key)) {
|
||||
return;
|
||||
}
|
||||
EXPIRES.put(key, expireTimeMillis);
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费持久化队列:返回 [token, expireMs] 并移除;无数据返回 null
|
||||
*/
|
||||
public static String[] pollCachedTokenPersist(String cacheKey) {
|
||||
return cacheKey == null ? null : persistQueue.remove(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费凭据更新队列:返回新凭据并移除;无数据返回 null
|
||||
*/
|
||||
public static String pollCredentialUpdate(String cacheKey) {
|
||||
return cacheKey == null ? null : credentialUpdateQueue.remove(cacheKey);
|
||||
public static boolean isExpired(String key) {
|
||||
Long expireTimeMillis = EXPIRES.get(key);
|
||||
return expireTimeMillis != null && System.currentTimeMillis() > expireTimeMillis;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ public class Ce4Tool extends PanBase {
|
||||
|
||||
try {
|
||||
URL url = new URL(shareLinkInfo.getShareUrl());
|
||||
assertPublicHost(url);
|
||||
String baseUrl = url.getProtocol() + "://" + url.getHost();
|
||||
// 如果有端口,拼接上端口
|
||||
if (url.getPort() != -1) {
|
||||
@@ -111,8 +110,7 @@ public class Ce4Tool extends PanBase {
|
||||
private void requestShareDetail(String baseUrl, String key, String pwd, String path) {
|
||||
String shareApiUrl = baseUrl + SHARE_API_PATH + key;
|
||||
|
||||
// 禁止跟随重定向:防止公网 host 302 到内网/元数据绕过 assertPublicHost
|
||||
HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
|
||||
HttpRequest<Buffer> httpRequest = clientSession.getAbs(shareApiUrl);
|
||||
if (pwd != null && !pwd.isEmpty()) {
|
||||
httpRequest.addQueryParam("password", pwd);
|
||||
}
|
||||
@@ -233,7 +231,7 @@ public class Ce4Tool extends PanBase {
|
||||
.put("uris", new JsonArray().add(filePath))
|
||||
.put("download", true);
|
||||
|
||||
clientNoRedirects.postAbs(fileUrlApi)
|
||||
clientSession.postAbs(fileUrlApi)
|
||||
.putHeader("Content-Type", "application/json")
|
||||
.sendJsonObject(requestBody)
|
||||
.onSuccess(res -> {
|
||||
|
||||
@@ -43,7 +43,6 @@ 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) {
|
||||
@@ -78,8 +77,7 @@ public class CeTool extends PanBase {
|
||||
private void tryV4Ping(String baseUrl, String key, String pwd) {
|
||||
String pingUrlV4 = baseUrl + PING_API_V4_PATH;
|
||||
|
||||
// 禁止跟随重定向:assertPublicHost 只校验初始 host,自动 30x 会绕过 SSRF 防护
|
||||
clientNoRedirects.getAbs(pingUrlV4).send().onSuccess(res -> {
|
||||
clientSession.getAbs(pingUrlV4).send().onSuccess(res -> {
|
||||
if (res.statusCode() == 200) {
|
||||
try {
|
||||
JsonObject json = asJson(res);
|
||||
@@ -109,7 +107,7 @@ public class CeTool extends PanBase {
|
||||
private void tryV3Ping(String baseUrl, String key, String pwd) {
|
||||
String pingUrlV3 = baseUrl + PING_API_V3_PATH;
|
||||
|
||||
clientNoRedirects.getAbs(pingUrlV3).send().onSuccess(res -> {
|
||||
clientSession.getAbs(pingUrlV3).send().onSuccess(res -> {
|
||||
if (res.statusCode() == 200) {
|
||||
try {
|
||||
JsonObject json = asJson(res);
|
||||
@@ -140,7 +138,7 @@ public class CeTool extends PanBase {
|
||||
*/
|
||||
private void verifyV3AndParse(String baseUrl, String key, String pwd) {
|
||||
String shareApiUrl = baseUrl + SHARE_API_PATH + key;
|
||||
HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
|
||||
HttpRequest<Buffer> httpRequest = clientSession.getAbs(shareApiUrl);
|
||||
if (pwd != null && !pwd.isEmpty()) {
|
||||
httpRequest.addQueryParam("password", pwd);
|
||||
}
|
||||
@@ -176,7 +174,7 @@ public class CeTool extends PanBase {
|
||||
*/
|
||||
private void tryV4ShareApi(String baseUrl, String key, String pwd) {
|
||||
String shareApiUrl = baseUrl + "/api/v4/share/info/" + key;
|
||||
HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
|
||||
HttpRequest<Buffer> httpRequest = clientSession.getAbs(shareApiUrl);
|
||||
if (pwd != null && !pwd.isEmpty()) {
|
||||
httpRequest.addQueryParam("password", pwd);
|
||||
}
|
||||
@@ -292,8 +290,7 @@ public class CeTool extends PanBase {
|
||||
}
|
||||
|
||||
private void getDownURL(String shareApiUrl) {
|
||||
// PUT 默认不跟随重定向,但仍统一使用 no-redirect 客户端避免配置漂移
|
||||
clientNoRedirects.putAbs(shareApiUrl)
|
||||
clientSession.putAbs(shareApiUrl)
|
||||
.putHeader("Referer", shareLinkInfo.getShareUrl())
|
||||
.send().onSuccess(res -> {
|
||||
JsonObject jsonObject = asJson(res);
|
||||
|
||||
@@ -22,7 +22,6 @@ public class FcTool extends PanBase {
|
||||
|
||||
public static final String SHARE_URL_PREFIX = "https://v2.fangcloud.com/sharing/";
|
||||
public static final String SHARE_URL_PREFIX2 = "https://v2.fangcloud.cn/sharing/";
|
||||
private static final String SHARE_INFO_URL = "https://v2.fangcloud.cn/apps/share_links/info/";
|
||||
private static final String DOWN_REQUEST_URL = "https://v2.fangcloud.cn/apps/files/download?file_id={fid}" +
|
||||
"&scenario=share&unique_name={uname}";
|
||||
|
||||
@@ -39,25 +38,6 @@ public class FcTool extends PanBase {
|
||||
final String dataKey = shareLinkInfo.getShareKey();
|
||||
final String pwd = shareLinkInfo.getSharePassword();
|
||||
WebClientSession sClient = WebClientSession.create(client);
|
||||
// 先查询分享有效性, 避免分享已失效/已过期时仍去解析HTML, 报出令人困惑的技术错误
|
||||
sClient.getAbs(SHARE_INFO_URL + dataKey).send().onSuccess(infoRes -> {
|
||||
JsonObject infoJson = asJson(infoRes);
|
||||
if (promise.future().isComplete()) {
|
||||
return;
|
||||
}
|
||||
JsonObject process = infoJson.getJsonObject("process");
|
||||
boolean isClosed = process != null && Boolean.TRUE.equals(process.getBoolean("is_closed"));
|
||||
boolean isExpired = process != null && Boolean.TRUE.equals(process.getBoolean("is_expired"));
|
||||
if (process == null || isClosed || isExpired) {
|
||||
fail("分享已失效或不存在");
|
||||
return;
|
||||
}
|
||||
doParse(dataKey, pwd, sClient);
|
||||
}).onFailure(handleFail(SHARE_INFO_URL + dataKey));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private void doParse(String dataKey, String pwd, WebClientSession sClient) {
|
||||
// 第一次请求 自动重定向
|
||||
sClient.getAbs(SHARE_URL_PREFIX + dataKey).send().onSuccess(res -> {
|
||||
|
||||
@@ -87,6 +67,7 @@ public class FcTool extends PanBase {
|
||||
}
|
||||
getDownURL(dataKey, promise, res, sClient);
|
||||
}).onFailure(handleFail(SHARE_URL_PREFIX + dataKey));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private void getDownURL(String dataKey, Promise<String> promise, HttpResponse<Buffer> res,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -265,8 +265,13 @@ public class UcTool extends PanBase {
|
||||
return;
|
||||
}
|
||||
String downloadUrl = dataList.getJsonObject(0).getString("download_url");
|
||||
// UC 需配合下载器(带 cookie),保存下载请求头
|
||||
completeWithMeta(downloadUrl, buildDownloadHeaders(null));
|
||||
// UC网盘需要配合aria2下载,保存下载请求头
|
||||
Map<String, String> downloadHeaders = new HashMap<>();
|
||||
// 将header转换为Map 只需要包含cookie,user-agent,referer
|
||||
downloadHeaders.put(HttpHeaders.COOKIE.toString(), header.get(HttpHeaders.COOKIE));
|
||||
downloadHeaders.put(HttpHeaders.USER_AGENT.toString(), header.get(HttpHeaders.USER_AGENT));
|
||||
downloadHeaders.put(HttpHeaders.REFERER.toString(), "https://fast.uc.cn/");
|
||||
completeWithMeta(downloadUrl, downloadHeaders);
|
||||
} catch (Exception e) {
|
||||
fail("解析 UC 下载链接失败: " + e.getMessage());
|
||||
}
|
||||
@@ -461,40 +466,29 @@ public class UcTool extends PanBase {
|
||||
if (shareFidToken != null) {
|
||||
extParams.put("share_fid_token", shareFidToken);
|
||||
}
|
||||
extParams.put("needDownloader", true);
|
||||
Map<String, String> dlHeaders = new HashMap<>();
|
||||
String listCookie = header.get(HttpHeaders.COOKIE);
|
||||
if (listCookie != null && !listCookie.isEmpty()) {
|
||||
dlHeaders.put(HttpHeaders.COOKIE.toString(), listCookie);
|
||||
}
|
||||
dlHeaders.put(HttpHeaders.USER_AGENT.toString(), header.get(HttpHeaders.USER_AGENT));
|
||||
dlHeaders.put(HttpHeaders.REFERER.toString(), "https://fast.uc.cn/");
|
||||
extParams.put("downloadHeaders", dlHeaders);
|
||||
fileInfo.setExtParameters(extParams);
|
||||
// 设置解析URL(用于下载)
|
||||
JsonObject paramJson = new JsonObject(extParams);
|
||||
paramJson.put("fileName", fileName);
|
||||
String param = CommonUtils.urlBase64Encode(paramJson.encode());
|
||||
// 透传 auth,避免下载/转存时变成 guest
|
||||
fileInfo.setParserUrl(appendAuthQuery(String.format("%s/v2/redirectUrl/%s/%s",
|
||||
getDomainName(), shareLinkInfo.getType(), param)));
|
||||
fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
|
||||
getDomainName(), shareLinkInfo.getType(), param));
|
||||
} else {
|
||||
// 文件夹
|
||||
fileInfo.setFileType("folder");
|
||||
fileInfo.setSize(0L);
|
||||
fileInfo.setSizeStr("0B");
|
||||
// 递归子目录须透传 auth,否则会丢失认证
|
||||
// 设置目录解析URL(用于递归解析子目录)
|
||||
// 对 URL 参数进行编码,确保特殊字符正确传递
|
||||
try {
|
||||
String encodedUrl = URLEncoder.encode(shareLinkInfo.getShareUrl(), StandardCharsets.UTF_8.toString());
|
||||
String encodedDirId = URLEncoder.encode(fid, StandardCharsets.UTF_8.toString());
|
||||
String encodedStoken = URLEncoder.encode(stoken, StandardCharsets.UTF_8.toString());
|
||||
fileInfo.setParserUrl(appendAuthQuery(String.format(
|
||||
"%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
|
||||
getDomainName(), encodedUrl, encodedDirId, encodedStoken)));
|
||||
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
|
||||
getDomainName(), encodedUrl, encodedDirId, encodedStoken));
|
||||
} catch (Exception e) {
|
||||
fileInfo.setParserUrl(appendAuthQuery(String.format(
|
||||
"%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
|
||||
getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken)));
|
||||
// 如果编码失败,使用原始值
|
||||
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
|
||||
getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,9 +510,6 @@ public class UcTool extends PanBase {
|
||||
promise.fail("缺少必要的参数");
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
// 会话无 cookie 时,回退使用入口参数中已带的 cookie
|
||||
ensureCookieFromParam(paramJson);
|
||||
|
||||
String fid = paramJson.getString("fid");
|
||||
String pwdId = paramJson.getString("pwd_id");
|
||||
@@ -563,11 +554,6 @@ public class UcTool extends PanBase {
|
||||
promise.fail("未找到下载链接");
|
||||
return;
|
||||
}
|
||||
// 存储下载请求头,供目录解析 getFileDownInfo 接口使用
|
||||
// 优先用当前会话 cookie;缺失时回退入口参数中已带的 cookie
|
||||
Map<String, String> downloadHeaders = buildDownloadHeaders(paramJson);
|
||||
shareLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders);
|
||||
shareLinkInfo.getOtherParam().put("fileName", paramJson.getString("fileName", ""));
|
||||
promise.complete(downloadUrl);
|
||||
} catch (Exception e) {
|
||||
promise.fail("解析 UC 下载链接失败: " + e.getMessage());
|
||||
@@ -578,53 +564,6 @@ public class UcTool extends PanBase {
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话无 cookie 时,从入口参数 downloadHeaders 回填到请求头。
|
||||
*/
|
||||
private void ensureCookieFromParam(JsonObject paramJson) {
|
||||
String cookie = header.get(HttpHeaders.COOKIE);
|
||||
if (cookie != null && !cookie.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String paramCookie = extractCookieFromParam(paramJson);
|
||||
if (paramCookie != null && !paramCookie.isEmpty()) {
|
||||
header.set(HttpHeaders.COOKIE, CookieUtils.filterUcQuarkCookie(paramCookie));
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractCookieFromParam(JsonObject paramJson) {
|
||||
if (paramJson == null) {
|
||||
return null;
|
||||
}
|
||||
JsonObject paramHeaders = paramJson.getJsonObject("downloadHeaders");
|
||||
if (paramHeaders == null) {
|
||||
return null;
|
||||
}
|
||||
String cookie = paramHeaders.getString("cookie");
|
||||
return cookie != null ? cookie : paramHeaders.getString("Cookie");
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建下载请求头:会话 cookie 优先,缺失时回退入口参数中的 downloadHeaders。
|
||||
*/
|
||||
private Map<String, String> buildDownloadHeaders(JsonObject paramJson) {
|
||||
Map<String, String> downloadHeaders = new HashMap<>();
|
||||
String cookie = header.get(HttpHeaders.COOKIE);
|
||||
if (cookie == null || cookie.isEmpty()) {
|
||||
cookie = extractCookieFromParam(paramJson);
|
||||
}
|
||||
if (cookie != null && !cookie.isEmpty()) {
|
||||
downloadHeaders.put(HttpHeaders.COOKIE.toString(), cookie);
|
||||
}
|
||||
String ua = header.get(HttpHeaders.USER_AGENT);
|
||||
if (ua == null || ua.isEmpty()) {
|
||||
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||
}
|
||||
downloadHeaders.put(HttpHeaders.USER_AGENT.toString(), ua);
|
||||
downloadHeaders.put(HttpHeaders.REFERER.toString(), "https://fast.uc.cn/");
|
||||
return downloadHeaders;
|
||||
}
|
||||
|
||||
// public static void main(String[] args) {
|
||||
// // https://drive.uc.cn/s/12450d1694844?public=1
|
||||
// new UcTool(ShareLinkInfo.newBuilder().shareKey("12450d1694844").build()).parse().onSuccess(
|
||||
|
||||
@@ -79,48 +79,13 @@ public class Ye2Tool extends PanBase {
|
||||
accountId = auths.get("_configId");
|
||||
} else if (auths.contains("username")) {
|
||||
accountId = auths.get("username");
|
||||
} else {
|
||||
String token = resolveProvidedToken(auths);
|
||||
if (StringUtils.isNotEmpty(token)) {
|
||||
accountId = token.substring(0, Math.min(16, token.length()));
|
||||
}
|
||||
} else if (auths.contains("token")) {
|
||||
String token = auths.get("token");
|
||||
accountId = token.substring(0, Math.min(16, token.length()));
|
||||
}
|
||||
return accountId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从配置/临时认证参数中取出可直接使用的 Bearer token。
|
||||
* 兼容两种 key:
|
||||
* - token:URLParamUtil 处理 auth= 临时参数(authType=accesstoken/authorization)时写入的字段名
|
||||
* - authorization:app-dev.yml 静态配置中更符合直觉的写法(auths.ye.authorization: xxx)
|
||||
*/
|
||||
private String resolveProvidedToken(MultiMap auths) {
|
||||
if (auths == null) {
|
||||
return null;
|
||||
}
|
||||
String token = auths.get("token");
|
||||
if (StringUtils.isEmpty(token)) {
|
||||
token = auths.get("authorization");
|
||||
}
|
||||
return stripBearerPrefix(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户/捐赠账号提供的 token 经常是从浏览器开发者工具里连同 "Bearer " 前缀一起复制过来的,
|
||||
* 如果不做清理,后续 header.set("Authorization", "Bearer " + token) 会拼出
|
||||
* "Bearer Bearer xxx",导致123网盘接口报错 tokenstring should not contain 'bearer '。
|
||||
*/
|
||||
private String stripBearerPrefix(String token) {
|
||||
if (StringUtils.isBlank(token)) {
|
||||
return token;
|
||||
}
|
||||
String trimmed = token.trim();
|
||||
if (trimmed.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
trimmed = trimmed.substring(7).trim();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private boolean isTokenExpired() {
|
||||
return TokenCache.isExpired(cacheKey);
|
||||
}
|
||||
@@ -224,14 +189,15 @@ public class Ye2Tool extends PanBase {
|
||||
private Future<String> resolveTokenFuture() {
|
||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
if (auths != null) {
|
||||
// 当同时提供了用户名+密码时,优先走真实登录流程。
|
||||
// 注:前端/auth参数中 authType=password|username_password 时,URLParamUtil 出于兼容旧解析器的目的
|
||||
// 会把 authToken(用户名) 同时写入 token 字段,如果这里无条件信任 token,会把用户名当成
|
||||
// Bearer token 使用,导致真实的账号密码登录被跳过。
|
||||
boolean hasCredential = StringUtils.isNotEmpty(auths.get("username"))
|
||||
&& StringUtils.isNotEmpty(auths.get("password"));
|
||||
String providedToken = resolveProvidedToken(auths);
|
||||
if (!hasCredential && StringUtils.isNotEmpty(providedToken)) {
|
||||
String raw = null;
|
||||
if (auths.contains("token")) {
|
||||
raw = auths.get("token");
|
||||
} else if (auths.contains("Authorization")) {
|
||||
raw = auths.get("Authorization");
|
||||
}
|
||||
String providedToken = normalizeBearerToken(raw);
|
||||
if (StringUtils.isNotEmpty(providedToken)) {
|
||||
log.info("使用配置token,前8位: {}...", providedToken.substring(0, Math.min(8, providedToken.length())));
|
||||
TokenCache.putToken(cacheKey, providedToken);
|
||||
return Future.succeededFuture(providedToken);
|
||||
}
|
||||
@@ -244,6 +210,17 @@ public class Ye2Tool extends PanBase {
|
||||
return Future.succeededFuture(cached);
|
||||
}
|
||||
|
||||
private String normalizeBearerToken(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
String token = raw.trim();
|
||||
if (token.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
token = token.substring(7).trim();
|
||||
}
|
||||
return StringUtils.isNotEmpty(token) ? token : null;
|
||||
}
|
||||
|
||||
private Future<String> loginAndGetToken() {
|
||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
if (auths == null) {
|
||||
@@ -641,14 +618,7 @@ public class Ye2Tool extends PanBase {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
String normalizedShareKey = YeShareHostUtil.normalizeShareKey(shareLinkInfo.getShareKey());
|
||||
if (StringUtils.isNotEmpty(normalizedShareKey)) {
|
||||
JsonObject v2Body = new JsonObject()
|
||||
.put("ShareKey", normalizedShareKey)
|
||||
|
||||
@@ -1,694 +0,0 @@
|
||||
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,备 cccpan.com / ysupan.com / uupan.net / ysok.net)
|
||||
* <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";
|
||||
/** 永硕目录内的子目录名(API 字段 zml),用于二级层级 */
|
||||
private static final String PARAM_ZML = "zml";
|
||||
|
||||
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());
|
||||
String zmlFilter = currentZmlFilter();
|
||||
fetchFiles(session, dirId).onSuccess(filesResp -> {
|
||||
try {
|
||||
listPromise.complete(mapFiles(session, dirId, filesResp, zmlFilter));
|
||||
} 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);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将目录内文件按 zml(子目录)分层。
|
||||
* <ul>
|
||||
* <li>zmlFilter 为空:返回子目录(folder)+ 根级文件</li>
|
||||
* <li>zmlFilter 非空:仅返回该子目录下的文件/链接</li>
|
||||
* </ul>
|
||||
*/
|
||||
private List<FileInfo> mapFiles(Session session, int dirId, JsonObject filesResp, String zmlFilter) {
|
||||
List<FileInfo> result = new ArrayList<>();
|
||||
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz", "");
|
||||
JsonArray lb = filesResp.getJsonArray("lb", new JsonArray());
|
||||
boolean listingSubdir = StringUtils.isNotBlank(zmlFilter);
|
||||
|
||||
// 未进入子目录时,先按出现顺序收集 zml 作为二级文件夹
|
||||
if (!listingSubdir) {
|
||||
java.util.LinkedHashSet<String> subdirs = new java.util.LinkedHashSet<>();
|
||||
for (int i = 0; i < lb.size(); i++) {
|
||||
JsonObject item = lb.getJsonObject(i);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String zml = StringUtils.defaultString(item.getString("zml")).trim();
|
||||
if (StringUtils.isNotBlank(zml)) {
|
||||
subdirs.add(zml);
|
||||
}
|
||||
}
|
||||
for (String zml : subdirs) {
|
||||
result.add(new FileInfo()
|
||||
.setFileName(zml)
|
||||
.setFileId(dirId + ":" + zml)
|
||||
.setFileType("folder")
|
||||
.setSize(0L)
|
||||
.setSizeStr("0B")
|
||||
.setFilePath(zml)
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&zml=%s&pwd=%s",
|
||||
getDomainName(),
|
||||
urlEncode(shareLinkInfo.getShareUrl()),
|
||||
dirId,
|
||||
urlEncode(zml),
|
||||
urlEncode(StringUtils.defaultString(shareLinkInfo.getSharePassword())))));
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < lb.size(); i++) {
|
||||
JsonObject item = lb.getJsonObject(i);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String itemZml = StringUtils.defaultString(item.getString("zml")).trim();
|
||||
if (listingSubdir) {
|
||||
if (!zmlFilter.equals(itemZml)) {
|
||||
continue;
|
||||
}
|
||||
} else if (StringUtils.isNotBlank(itemZml)) {
|
||||
// 根级列表只展示无 zml 的条目,有 zml 的归入子目录
|
||||
continue;
|
||||
}
|
||||
|
||||
Integer bh = item.getInteger("bh");
|
||||
if (bh == null) {
|
||||
continue;
|
||||
}
|
||||
String wjlx = item.getString("wjlx", "");
|
||||
|
||||
// URL / 公告条目
|
||||
if ("url".equalsIgnoreCase(wjlx)) {
|
||||
String link = StringUtils.defaultString(item.getString("wjm")).trim();
|
||||
String title = StringUtils.defaultString(item.getString("bt")).trim();
|
||||
// 空占位(标题和链接都空)跳过,与官网展示一致
|
||||
if (StringUtils.isAllBlank(title, link)) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isBlank(title)) {
|
||||
title = StringUtils.defaultIfBlank(link, "链接");
|
||||
}
|
||||
FileInfo urlInfo = new FileInfo()
|
||||
.setFileName(title)
|
||||
.setFileId(bh.toString())
|
||||
.setFileType("url")
|
||||
.setSize(0L)
|
||||
.setSizeStr("0B")
|
||||
.setFilePath(itemZml)
|
||||
.setCreateTime(normalizeTime(item.getString("sj")))
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
.setPreviewUrl(link)
|
||||
.setDescription(link);
|
||||
// parserUrl 置空,避免前端误走下载;打开走 previewUrl
|
||||
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);
|
||||
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(itemZml)
|
||||
.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 String currentZmlFilter() {
|
||||
Object zml = shareLinkInfo.getOtherParam().get(PARAM_ZML);
|
||||
if (zml == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return java.net.URLDecoder.decode(zml.toString(), StandardCharsets.UTF_8).trim();
|
||||
} catch (Exception e) {
|
||||
return zml.toString().trim();
|
||||
}
|
||||
}
|
||||
|
||||
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"));
|
||||
|
||||
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 + "/"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼装直链。注意:不要在 xzpz 前加 "_",官方页面直链无此前缀,加了会 404。
|
||||
*/
|
||||
static String buildDownloadUrl(String space, String xzpz, String pz, String fwq, String fileName) {
|
||||
String host = "X".equalsIgnoreCase(fwq)
|
||||
? "y.ys168.com:8000"
|
||||
: "ys-" + fwq.toLowerCase() + ".ysepan.com";
|
||||
return "https://" + host + "/wap/"
|
||||
+ encodePathSegment(space) + "/"
|
||||
+ encodePathSegment(xzpz) + "/"
|
||||
+ 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) {
|
||||
}
|
||||
}
|
||||
@@ -77,57 +77,32 @@ public class CommonUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码路径参数中的 Base64。
|
||||
* <p>优先按 URL-Safe Base64 解;兼容历史「标准 Base64 + URLEncode」以及重复 encode。</p>
|
||||
* urlEncode -> deBase64 -> string
|
||||
* @param encoded 编码后的字符串
|
||||
* @return 解码后的字符串
|
||||
*/
|
||||
public static String urlBase64Decode(String encoded) {
|
||||
if (encoded == null || encoded.isEmpty()) {
|
||||
throw new RuntimeException("URL Base64 解码失败: empty");
|
||||
try {
|
||||
String urlDecoded = java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8);
|
||||
byte[] base64DecodedBytes = java.util.Base64.getDecoder().decode(urlDecoded);
|
||||
return new String(base64DecodedBytes, java.nio.charset.StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("URL Base64 解码失败", e);
|
||||
}
|
||||
String s = encoded.trim().replace(' ', '+');
|
||||
// 兼容历史 URLEncode / 误二次 encode:有 % 则解到不再变化
|
||||
for (int i = 0; i < 3 && s.contains("%"); i++) {
|
||||
try {
|
||||
String next = java.net.URLDecoder.decode(s, StandardCharsets.UTF_8);
|
||||
if (next.equals(s)) {
|
||||
break;
|
||||
}
|
||||
s = next;
|
||||
} catch (Exception e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Exception last = null;
|
||||
for (String candidate : new String[]{s, padBase64(s)}) {
|
||||
try {
|
||||
return new String(java.util.Base64.getUrlDecoder().decode(candidate), StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
last = e;
|
||||
}
|
||||
try {
|
||||
return new String(java.util.Base64.getDecoder().decode(candidate), StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
last = e;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException("URL Base64 解码失败", last);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 编码为可直接放进 URL path 的 Base64(URL-Safe,无 padding)。
|
||||
* <p>不再做 URLEncoder,避免前端/代理再 encode 时变成 %253D。</p>
|
||||
* string -> base64Encode -> urlEncode
|
||||
* @param str 原始字符串
|
||||
* @return 编码后的字符串
|
||||
*/
|
||||
public static String urlBase64Encode(String str) {
|
||||
return java.util.Base64.getUrlEncoder()
|
||||
.withoutPadding()
|
||||
.encodeToString(str.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String padBase64(String s) {
|
||||
int mod = s.length() % 4;
|
||||
if (mod == 0) {
|
||||
return s;
|
||||
try {
|
||||
byte[] base64EncodedBytes = java.util.Base64.getEncoder().encode(str.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
String base64Encoded = new String(base64EncodedBytes, java.nio.charset.StandardCharsets.UTF_8);
|
||||
return java.net.URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("URL Base64 编码失败", e);
|
||||
}
|
||||
return s + "====".substring(mod);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,18 +119,8 @@ public class HttpResponseHelper {
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean looksLikeGzip(Buffer compressed) {
|
||||
return compressed != null && compressed.length() >= 2
|
||||
&& (compressed.getByte(0) & 0xff) == 0x1f
|
||||
&& (compressed.getByte(1) & 0xff) == 0x8b;
|
||||
}
|
||||
|
||||
// -------------------- gzip --------------------
|
||||
private static String decompressGzip(Buffer compressed) throws IOException {
|
||||
// Vert.x 可能已解压但仍带 Content-Encoding: gzip,再走 GZIPInputStream 会变成 ZipException
|
||||
if (!looksLikeGzip(compressed)) {
|
||||
return compressed.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
try (ByteArrayInputStream bais = new ByteArrayInputStream(compressed.getBytes());
|
||||
GZIPInputStream gzis = new GZIPInputStream(bais);
|
||||
InputStreamReader isr = new InputStreamReader(gzis, StandardCharsets.UTF_8);
|
||||
|
||||
@@ -101,384 +101,59 @@ public interface JsContent {
|
||||
""";
|
||||
String lz = """
|
||||
/**
|
||||
* 蓝奏云解析器 JS 沙箱:伪装 jQuery / document / window。
|
||||
* 新版页面会用 document.cookie、location.reload、querySelector、
|
||||
* $('#pwd').val()、.html()、.css() 等,这里做成可链式的最小实现。
|
||||
* kdns.js 在浏览器里是 `var killdns = true`,需一并注入,否则 kd 会被改成 0。
|
||||
* 蓝奏云解析器js签名获取工具
|
||||
*/
|
||||
|
||||
var signObj;
|
||||
var __lzPwd = '';
|
||||
var killdns = true;
|
||||
|
||||
function __lzSetPwd(p) {
|
||||
__lzPwd = p == null ? '' : String(p);
|
||||
}
|
||||
|
||||
function __lzEl(id) {
|
||||
var nid = String(id == null ? '' : id).replace(/^[#.]/, '');
|
||||
var el = {
|
||||
id: nid,
|
||||
_value: nid === 'pwd' ? __lzPwd : '',
|
||||
checked: false,
|
||||
disabled: false,
|
||||
innerHTML: '',
|
||||
innerText: '',
|
||||
textContent: '',
|
||||
className: '',
|
||||
style: { display: '', visibility: '', width: '', height: '' },
|
||||
classList: {
|
||||
add: function () {},
|
||||
remove: function () {},
|
||||
contains: function () { return false; },
|
||||
toggle: function () {}
|
||||
},
|
||||
setAttribute: function () {},
|
||||
getAttribute: function () { return null; },
|
||||
removeAttribute: function () {},
|
||||
addEventListener: function (t, fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
},
|
||||
removeEventListener: function () {},
|
||||
appendChild: function (n) { return n; },
|
||||
removeChild: function (n) { return n; },
|
||||
insertBefore: function (n) { return n; },
|
||||
click: function () {},
|
||||
focus: function () {},
|
||||
blur: function () {},
|
||||
submit: function () {},
|
||||
select: function () {},
|
||||
reset: function () {}
|
||||
};
|
||||
el.parentNode = el;
|
||||
el.parentElement = el;
|
||||
el.children = [];
|
||||
el.childNodes = [];
|
||||
el.firstChild = null;
|
||||
el.lastChild = null;
|
||||
try {
|
||||
Object.defineProperty(el, 'value', {
|
||||
get: function () { return nid === 'pwd' ? __lzPwd : el._value; },
|
||||
set: function (v) {
|
||||
el._value = v;
|
||||
if (nid === 'pwd') {
|
||||
__lzPwd = v == null ? '' : String(v);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
el.value = el._value;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function __lzJq(sel) {
|
||||
var id = '';
|
||||
if (typeof sel === 'string') {
|
||||
id = sel.replace(/^[#.]/, '');
|
||||
} else if (sel && sel.id) {
|
||||
id = String(sel.id);
|
||||
}
|
||||
var el = (sel && sel.style && sel.addEventListener) ? sel : __lzEl(id);
|
||||
var api = {
|
||||
0: el,
|
||||
length: 1,
|
||||
selector: sel,
|
||||
ready: function (fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(jQuery); } catch (e) {}
|
||||
}
|
||||
return api;
|
||||
},
|
||||
on: function (t, fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
return api;
|
||||
},
|
||||
off: function () { return api; },
|
||||
bind: function (t, fn) { return api.on(t, fn); },
|
||||
unbind: function () { return api; },
|
||||
click: function (fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
return api;
|
||||
},
|
||||
focus: function (fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
return api;
|
||||
},
|
||||
blur: function () { return api; },
|
||||
keyup: function (fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
return api;
|
||||
},
|
||||
keydown: function (fn) { return api.keyup(fn); },
|
||||
keypress: function (fn) { return api.keyup(fn); },
|
||||
submit: function (fn) { return api.click(fn); },
|
||||
change: function (fn) { return api.click(fn); },
|
||||
hover: function () { return api; },
|
||||
val: function (v) {
|
||||
if (arguments.length === 0) {
|
||||
if (typeof el.value !== 'undefined') {
|
||||
return el.value;
|
||||
}
|
||||
return (id === 'pwd') ? __lzPwd : '';
|
||||
}
|
||||
el.value = v;
|
||||
return api;
|
||||
},
|
||||
html: function (v) {
|
||||
if (arguments.length === 0) {
|
||||
return el.innerHTML;
|
||||
}
|
||||
el.innerHTML = v;
|
||||
return api;
|
||||
},
|
||||
text: function (v) {
|
||||
if (arguments.length === 0) {
|
||||
return el.innerText;
|
||||
}
|
||||
el.innerText = v;
|
||||
el.textContent = v;
|
||||
return api;
|
||||
},
|
||||
attr: function (k, v) {
|
||||
if (arguments.length < 2) {
|
||||
return null;
|
||||
}
|
||||
return api;
|
||||
},
|
||||
prop: function (k, v) {
|
||||
if (arguments.length < 2) {
|
||||
return false;
|
||||
}
|
||||
return api;
|
||||
},
|
||||
css: function () { return api; },
|
||||
addClass: function () { return api; },
|
||||
removeClass: function () { return api; },
|
||||
toggleClass: function () { return api; },
|
||||
hasClass: function () { return false; },
|
||||
show: function () {
|
||||
el.style.display = '';
|
||||
return api;
|
||||
},
|
||||
hide: function () {
|
||||
el.style.display = 'none';
|
||||
return api;
|
||||
},
|
||||
fadeIn: function () { return api; },
|
||||
fadeOut: function () { return api; },
|
||||
animate: function () { return api; },
|
||||
find: function () { return api; },
|
||||
parent: function () { return api; },
|
||||
children: function () { return api; },
|
||||
eq: function () { return api; },
|
||||
first: function () { return api; },
|
||||
last: function () { return api; },
|
||||
each: function (fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn.call(el, 0, el); } catch (e) {}
|
||||
}
|
||||
return api;
|
||||
},
|
||||
append: function () { return api; },
|
||||
prepend: function () { return api; },
|
||||
remove: function () { return api; },
|
||||
empty: function () { return api; },
|
||||
ajax: function (obj) {
|
||||
signObj = obj;
|
||||
return api;
|
||||
},
|
||||
get: function () { return el; }
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
var $, jQuery;
|
||||
$ = jQuery = function (sel) {
|
||||
if (typeof sel === 'function') {
|
||||
try { sel(jQuery); } catch (e) {}
|
||||
return __lzJq(document);
|
||||
}
|
||||
return __lzJq(sel);
|
||||
};
|
||||
|
||||
$ = jQuery = function () {
|
||||
return new jQuery.fn.init();
|
||||
}
|
||||
|
||||
jQuery.fn = jQuery.prototype = {
|
||||
init: function (sel) {
|
||||
return __lzJq(sel);
|
||||
}
|
||||
};
|
||||
init: function () {
|
||||
return {
|
||||
focus: function (a) {
|
||||
|
||||
},
|
||||
keyup: function(a) {
|
||||
|
||||
},
|
||||
ajax: function (obj) {
|
||||
signObj = obj
|
||||
},
|
||||
val: function(a) {
|
||||
|
||||
},
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
jQuery.fn.init.prototype = jQuery.fn;
|
||||
$.fn = jQuery.fn;
|
||||
|
||||
|
||||
$.ajax = function (obj) {
|
||||
signObj = obj;
|
||||
return {
|
||||
done: function () { return this; },
|
||||
fail: function () { return this; },
|
||||
always: function () { return this; }
|
||||
};
|
||||
};
|
||||
$.get = function () {};
|
||||
$.post = function () {};
|
||||
$.extend = function () {
|
||||
var t = arguments[0] || {};
|
||||
for (var i = 1; i < arguments.length; i++) {
|
||||
var s = arguments[i];
|
||||
if (s) {
|
||||
for (var k in s) {
|
||||
if (s.hasOwnProperty(k)) {
|
||||
t[k] = s[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return t;
|
||||
};
|
||||
$.each = function (obj, fn) {
|
||||
if (!obj || typeof fn !== 'function') {
|
||||
return obj;
|
||||
}
|
||||
if (typeof obj.length === 'number') {
|
||||
for (var i = 0; i < obj.length; i++) {
|
||||
fn.call(obj[i], i, obj[i]);
|
||||
}
|
||||
} else {
|
||||
for (var k in obj) {
|
||||
if (obj.hasOwnProperty(k)) {
|
||||
fn.call(obj[k], k, obj[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
$.isFunction = function (f) { return typeof f === 'function'; };
|
||||
$.isArray = function (a) {
|
||||
return Object.prototype.toString.call(a) === '[object Array]';
|
||||
};
|
||||
$.trim = function (s) {
|
||||
return s == null ? '' : String(s).replace(/^\\s+|\\s+$/g, '');
|
||||
};
|
||||
|
||||
var __lzLocation = {
|
||||
href: '',
|
||||
search: '',
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
host: '',
|
||||
hostname: '',
|
||||
protocol: 'https:',
|
||||
port: '',
|
||||
origin: '',
|
||||
assign: function () {},
|
||||
replace: function () {},
|
||||
reload: function () {}
|
||||
};
|
||||
signObj = obj
|
||||
}
|
||||
|
||||
var document = {
|
||||
cookie: '',
|
||||
title: '',
|
||||
domain: '',
|
||||
referrer: '',
|
||||
readyState: 'complete',
|
||||
hidden: false,
|
||||
visibilityState: 'visible',
|
||||
documentElement: null,
|
||||
body: null,
|
||||
head: null,
|
||||
location: __lzLocation,
|
||||
getElementById: function (id) { return __lzEl(id); },
|
||||
getElementsByClassName: function () { return []; },
|
||||
getElementsByTagName: function (t) {
|
||||
return t === 'script' ? [] : [__lzEl(t)];
|
||||
},
|
||||
getElementsByName: function () { return []; },
|
||||
querySelector: function (s) { return __lzEl(s); },
|
||||
querySelectorAll: function (s) { return [__lzEl(s)]; },
|
||||
createElement: function (t) { return __lzEl(t); },
|
||||
createTextNode: function (t) { return { nodeValue: t, data: t }; },
|
||||
createDocumentFragment: function () { return __lzEl('fragment'); },
|
||||
addEventListener: function (t, fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(); } catch (e) {}
|
||||
getElementById: function (v) {
|
||||
return {
|
||||
value: 'v',
|
||||
style: {
|
||||
display: ''
|
||||
},
|
||||
addEventListener: function() {}
|
||||
}
|
||||
},
|
||||
removeEventListener: function () {},
|
||||
write: function () {},
|
||||
writeln: function () {},
|
||||
open: function () {},
|
||||
close: function () {}
|
||||
};
|
||||
document.documentElement = __lzEl('html');
|
||||
document.body = __lzEl('body');
|
||||
document.head = __lzEl('head');
|
||||
|
||||
var navigator = {
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
|
||||
platform: 'Win32',
|
||||
language: 'zh-CN',
|
||||
cookieEnabled: true,
|
||||
onLine: true
|
||||
};
|
||||
|
||||
var location = __lzLocation;
|
||||
var console = {
|
||||
log: function () {},
|
||||
warn: function () {},
|
||||
error: function () {},
|
||||
info: function () {},
|
||||
debug: function () {}
|
||||
};
|
||||
|
||||
function setTimeout(fn, delay) {
|
||||
if (typeof fn === 'function' && (!delay || delay <= 0)) {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function setInterval() { return 0; }
|
||||
function clearTimeout() {}
|
||||
function clearInterval() {}
|
||||
|
||||
var window = {
|
||||
location: __lzLocation,
|
||||
document: document,
|
||||
navigator: navigator,
|
||||
console: console,
|
||||
innerWidth: 1920,
|
||||
innerHeight: 1080,
|
||||
setTimeout: setTimeout,
|
||||
setInterval: setInterval,
|
||||
clearTimeout: clearTimeout,
|
||||
clearInterval: clearInterval,
|
||||
addEventListener: function (t, fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
},
|
||||
removeEventListener: function () {},
|
||||
atob: function (s) { return s; },
|
||||
btoa: function (s) { return s; }
|
||||
};
|
||||
window.window = window;
|
||||
window.top = window;
|
||||
window.self = window;
|
||||
window.parent = window;
|
||||
window.jQuery = jQuery;
|
||||
window.$ = $;
|
||||
|
||||
var top = window;
|
||||
var self = window;
|
||||
var parent = window;
|
||||
|
||||
var window = {location: {}}
|
||||
""";
|
||||
|
||||
String kwSignString = """
|
||||
|
||||
@@ -50,47 +50,21 @@ public class JsExecUtils {
|
||||
*/
|
||||
public static ScriptObjectMirror executeDynamicJs(String jsText, String funName) throws ScriptException,
|
||||
NoSuchMethodException {
|
||||
return executeDynamicJs(jsText, funName, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param pwd 分享密码,写入伪 DOM(#pwd / getElementById('pwd')),供新版页面取值
|
||||
*/
|
||||
public static ScriptObjectMirror executeDynamicJs(String jsText, String funName, String pwd) throws ScriptException,
|
||||
NoSuchMethodException {
|
||||
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript");
|
||||
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎
|
||||
try {
|
||||
engine.eval(JsContent.lz);
|
||||
engine.eval(JsContent.lz + "\n" + jsText);
|
||||
Invocable inv = (Invocable) engine;
|
||||
if (pwd != null) {
|
||||
inv.invokeFunction("__lzSetPwd", pwd);
|
||||
//调用js中的函数
|
||||
if (StringUtils.isNotEmpty(funName)) {
|
||||
inv.invokeFunction(funName);
|
||||
}
|
||||
try {
|
||||
engine.eval(jsText);
|
||||
if (StringUtils.isNotEmpty(funName)) {
|
||||
inv.invokeFunction(funName);
|
||||
}
|
||||
} catch (ScriptException | NoSuchMethodException | RuntimeException e) {
|
||||
ScriptObjectMirror captured = asSignObj(engine.get("signObj"));
|
||||
if (captured != null) {
|
||||
return captured;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return asSignObj(engine.get("signObj"));
|
||||
return (ScriptObjectMirror) engine.get("signObj");
|
||||
} finally {
|
||||
// 清理引擎持有的引用,帮助 GC 回收
|
||||
clearEngineBindings(engine);
|
||||
}
|
||||
}
|
||||
|
||||
private static ScriptObjectMirror asSignObj(Object sign) {
|
||||
if (sign instanceof ScriptObjectMirror mirror
|
||||
&& (mirror.get("url") != null || mirror.get("data") != null)) {
|
||||
return mirror;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 调用执行js文件(使用缓存的 ScriptEngineManager 创建新引擎实例)
|
||||
|
||||
@@ -1,379 +1,46 @@
|
||||
/**
|
||||
* 蓝奏云解析器 JS 沙箱:伪装 jQuery / document / window。
|
||||
* 新版页面会用 document.cookie、location.reload、querySelector、
|
||||
* $('#pwd').val()、.html()、.css() 等,这里做成可链式的最小实现。
|
||||
* kdns.js 在浏览器里是 `var killdns = true`,需一并注入,否则 kd 会被改成 0。
|
||||
* 蓝奏云解析器js签名获取工具
|
||||
*/
|
||||
|
||||
var signObj;
|
||||
var __lzPwd = '';
|
||||
var killdns = true;
|
||||
|
||||
function __lzSetPwd(p) {
|
||||
__lzPwd = p == null ? '' : String(p);
|
||||
}
|
||||
|
||||
function __lzEl(id) {
|
||||
var nid = String(id == null ? '' : id).replace(/^[#.]/, '');
|
||||
var el = {
|
||||
id: nid,
|
||||
_value: nid === 'pwd' ? __lzPwd : '',
|
||||
checked: false,
|
||||
disabled: false,
|
||||
innerHTML: '',
|
||||
innerText: '',
|
||||
textContent: '',
|
||||
className: '',
|
||||
style: { display: '', visibility: '', width: '', height: '' },
|
||||
classList: {
|
||||
add: function () {},
|
||||
remove: function () {},
|
||||
contains: function () { return false; },
|
||||
toggle: function () {}
|
||||
},
|
||||
setAttribute: function () {},
|
||||
getAttribute: function () { return null; },
|
||||
removeAttribute: function () {},
|
||||
addEventListener: function (t, fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
},
|
||||
removeEventListener: function () {},
|
||||
appendChild: function (n) { return n; },
|
||||
removeChild: function (n) { return n; },
|
||||
insertBefore: function (n) { return n; },
|
||||
click: function () {},
|
||||
focus: function () {},
|
||||
blur: function () {},
|
||||
submit: function () {},
|
||||
select: function () {},
|
||||
reset: function () {}
|
||||
};
|
||||
el.parentNode = el;
|
||||
el.parentElement = el;
|
||||
el.children = [];
|
||||
el.childNodes = [];
|
||||
el.firstChild = null;
|
||||
el.lastChild = null;
|
||||
try {
|
||||
Object.defineProperty(el, 'value', {
|
||||
get: function () { return nid === 'pwd' ? __lzPwd : el._value; },
|
||||
set: function (v) {
|
||||
el._value = v;
|
||||
if (nid === 'pwd') {
|
||||
__lzPwd = v == null ? '' : String(v);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
el.value = el._value;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function __lzJq(sel) {
|
||||
var id = '';
|
||||
if (typeof sel === 'string') {
|
||||
id = sel.replace(/^[#.]/, '');
|
||||
} else if (sel && sel.id) {
|
||||
id = String(sel.id);
|
||||
}
|
||||
var el = (sel && sel.style && sel.addEventListener) ? sel : __lzEl(id);
|
||||
var api = {
|
||||
0: el,
|
||||
length: 1,
|
||||
selector: sel,
|
||||
ready: function (fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(jQuery); } catch (e) {}
|
||||
}
|
||||
return api;
|
||||
},
|
||||
on: function (t, fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
return api;
|
||||
},
|
||||
off: function () { return api; },
|
||||
bind: function (t, fn) { return api.on(t, fn); },
|
||||
unbind: function () { return api; },
|
||||
click: function (fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
return api;
|
||||
},
|
||||
focus: function (fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
return api;
|
||||
},
|
||||
blur: function () { return api; },
|
||||
keyup: function (fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
return api;
|
||||
},
|
||||
keydown: function (fn) { return api.keyup(fn); },
|
||||
keypress: function (fn) { return api.keyup(fn); },
|
||||
submit: function (fn) { return api.click(fn); },
|
||||
change: function (fn) { return api.click(fn); },
|
||||
hover: function () { return api; },
|
||||
val: function (v) {
|
||||
if (arguments.length === 0) {
|
||||
if (typeof el.value !== 'undefined') {
|
||||
return el.value;
|
||||
}
|
||||
return (id === 'pwd') ? __lzPwd : '';
|
||||
}
|
||||
el.value = v;
|
||||
return api;
|
||||
},
|
||||
html: function (v) {
|
||||
if (arguments.length === 0) {
|
||||
return el.innerHTML;
|
||||
}
|
||||
el.innerHTML = v;
|
||||
return api;
|
||||
},
|
||||
text: function (v) {
|
||||
if (arguments.length === 0) {
|
||||
return el.innerText;
|
||||
}
|
||||
el.innerText = v;
|
||||
el.textContent = v;
|
||||
return api;
|
||||
},
|
||||
attr: function (k, v) {
|
||||
if (arguments.length < 2) {
|
||||
return null;
|
||||
}
|
||||
return api;
|
||||
},
|
||||
prop: function (k, v) {
|
||||
if (arguments.length < 2) {
|
||||
return false;
|
||||
}
|
||||
return api;
|
||||
},
|
||||
css: function () { return api; },
|
||||
addClass: function () { return api; },
|
||||
removeClass: function () { return api; },
|
||||
toggleClass: function () { return api; },
|
||||
hasClass: function () { return false; },
|
||||
show: function () {
|
||||
el.style.display = '';
|
||||
return api;
|
||||
},
|
||||
hide: function () {
|
||||
el.style.display = 'none';
|
||||
return api;
|
||||
},
|
||||
fadeIn: function () { return api; },
|
||||
fadeOut: function () { return api; },
|
||||
animate: function () { return api; },
|
||||
find: function () { return api; },
|
||||
parent: function () { return api; },
|
||||
children: function () { return api; },
|
||||
eq: function () { return api; },
|
||||
first: function () { return api; },
|
||||
last: function () { return api; },
|
||||
each: function (fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn.call(el, 0, el); } catch (e) {}
|
||||
}
|
||||
return api;
|
||||
},
|
||||
append: function () { return api; },
|
||||
prepend: function () { return api; },
|
||||
remove: function () { return api; },
|
||||
empty: function () { return api; },
|
||||
ajax: function (obj) {
|
||||
signObj = obj;
|
||||
return api;
|
||||
},
|
||||
get: function () { return el; }
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
var $, jQuery;
|
||||
$ = jQuery = function (sel) {
|
||||
if (typeof sel === 'function') {
|
||||
try { sel(jQuery); } catch (e) {}
|
||||
return __lzJq(document);
|
||||
}
|
||||
return __lzJq(sel);
|
||||
};
|
||||
|
||||
$ = jQuery = function () {
|
||||
return new jQuery.fn.init();
|
||||
}
|
||||
|
||||
jQuery.fn = jQuery.prototype = {
|
||||
init: function (sel) {
|
||||
return __lzJq(sel);
|
||||
}
|
||||
};
|
||||
init: function () {
|
||||
return {
|
||||
focus: function (a) {
|
||||
|
||||
},
|
||||
keyup: function(a) {
|
||||
|
||||
},
|
||||
ajax: function (obj) {
|
||||
signObj = obj
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
jQuery.fn.init.prototype = jQuery.fn;
|
||||
$.fn = jQuery.fn;
|
||||
|
||||
|
||||
// 伪装jquery.ajax函数获取关键数据
|
||||
$.ajax = function (obj) {
|
||||
signObj = obj;
|
||||
return {
|
||||
done: function () { return this; },
|
||||
fail: function () { return this; },
|
||||
always: function () { return this; }
|
||||
};
|
||||
};
|
||||
$.get = function () {};
|
||||
$.post = function () {};
|
||||
$.extend = function () {
|
||||
var t = arguments[0] || {};
|
||||
for (var i = 1; i < arguments.length; i++) {
|
||||
var s = arguments[i];
|
||||
if (s) {
|
||||
for (var k in s) {
|
||||
if (s.hasOwnProperty(k)) {
|
||||
t[k] = s[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return t;
|
||||
};
|
||||
$.each = function (obj, fn) {
|
||||
if (!obj || typeof fn !== 'function') {
|
||||
return obj;
|
||||
}
|
||||
if (typeof obj.length === 'number') {
|
||||
for (var i = 0; i < obj.length; i++) {
|
||||
fn.call(obj[i], i, obj[i]);
|
||||
}
|
||||
} else {
|
||||
for (var k in obj) {
|
||||
if (obj.hasOwnProperty(k)) {
|
||||
fn.call(obj[k], k, obj[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
$.isFunction = function (f) { return typeof f === 'function'; };
|
||||
$.isArray = function (a) {
|
||||
return Object.prototype.toString.call(a) === '[object Array]';
|
||||
};
|
||||
$.trim = function (s) {
|
||||
return s == null ? '' : String(s).replace(/^\s+|\s+$/g, '');
|
||||
};
|
||||
|
||||
var __lzLocation = {
|
||||
href: '',
|
||||
search: '',
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
host: '',
|
||||
hostname: '',
|
||||
protocol: 'https:',
|
||||
port: '',
|
||||
origin: '',
|
||||
assign: function () {},
|
||||
replace: function () {},
|
||||
reload: function () {}
|
||||
};
|
||||
signObj = obj
|
||||
}
|
||||
|
||||
var document = {
|
||||
cookie: '',
|
||||
title: '',
|
||||
domain: '',
|
||||
referrer: '',
|
||||
readyState: 'complete',
|
||||
hidden: false,
|
||||
visibilityState: 'visible',
|
||||
documentElement: null,
|
||||
body: null,
|
||||
head: null,
|
||||
location: __lzLocation,
|
||||
getElementById: function (id) { return __lzEl(id); },
|
||||
getElementsByClassName: function () { return []; },
|
||||
getElementsByTagName: function (t) {
|
||||
return t === 'script' ? [] : [__lzEl(t)];
|
||||
},
|
||||
getElementsByName: function () { return []; },
|
||||
querySelector: function (s) { return __lzEl(s); },
|
||||
querySelectorAll: function (s) { return [__lzEl(s)]; },
|
||||
createElement: function (t) { return __lzEl(t); },
|
||||
createTextNode: function (t) { return { nodeValue: t, data: t }; },
|
||||
createDocumentFragment: function () { return __lzEl('fragment'); },
|
||||
addEventListener: function (t, fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(); } catch (e) {}
|
||||
getElementById: function (v) {
|
||||
return {
|
||||
value: 'v'
|
||||
}
|
||||
},
|
||||
removeEventListener: function () {},
|
||||
write: function () {},
|
||||
writeln: function () {},
|
||||
open: function () {},
|
||||
close: function () {}
|
||||
};
|
||||
document.documentElement = __lzEl('html');
|
||||
document.body = __lzEl('body');
|
||||
document.head = __lzEl('head');
|
||||
|
||||
var navigator = {
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
|
||||
platform: 'Win32',
|
||||
language: 'zh-CN',
|
||||
cookieEnabled: true,
|
||||
onLine: true
|
||||
};
|
||||
|
||||
var location = __lzLocation;
|
||||
var console = {
|
||||
log: function () {},
|
||||
warn: function () {},
|
||||
error: function () {},
|
||||
info: function () {},
|
||||
debug: function () {}
|
||||
};
|
||||
|
||||
function setTimeout(fn, delay) {
|
||||
if (typeof fn === 'function' && (!delay || delay <= 0)) {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function setInterval() { return 0; }
|
||||
function clearTimeout() {}
|
||||
function clearInterval() {}
|
||||
|
||||
var window = {
|
||||
location: __lzLocation,
|
||||
document: document,
|
||||
navigator: navigator,
|
||||
console: console,
|
||||
innerWidth: 1920,
|
||||
innerHeight: 1080,
|
||||
setTimeout: setTimeout,
|
||||
setInterval: setInterval,
|
||||
clearTimeout: clearTimeout,
|
||||
clearInterval: clearInterval,
|
||||
addEventListener: function (t, fn) {
|
||||
if (typeof fn === 'function') {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
},
|
||||
removeEventListener: function () {},
|
||||
atob: function (s) { return s; },
|
||||
btoa: function (s) { return s; }
|
||||
};
|
||||
window.window = window;
|
||||
window.top = window;
|
||||
window.self = window;
|
||||
window.parent = window;
|
||||
window.jQuery = jQuery;
|
||||
window.$ = $;
|
||||
|
||||
var top = window;
|
||||
var self = window;
|
||||
var parent = window;
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package cn.qaiu.parser;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
/**
|
||||
* GHSA-997r-7xx2-p9x6 regression: Cloudreve generic parser must reject
|
||||
* hosts that resolve to loopback / private / link-local / metadata ranges
|
||||
* before any outbound request.
|
||||
*/
|
||||
public class AssertPublicHostTest {
|
||||
|
||||
@Test
|
||||
public void rejectsLoopbackAndPrivateHosts() throws Exception {
|
||||
String[] blocked = {
|
||||
"http://127.0.0.1.nip.io/s/poc",
|
||||
"http://localhost/s/poc",
|
||||
"http://10.0.0.1/s/poc",
|
||||
"http://192.168.1.1/s/poc",
|
||||
"http://172.16.0.1/s/poc",
|
||||
"http://169.254.169.254/s/poc",
|
||||
"http://[::1]/s/poc"
|
||||
};
|
||||
for (String raw : blocked) {
|
||||
try {
|
||||
PanBase.assertPublicHost(new URL(raw));
|
||||
fail("expected block for " + raw);
|
||||
} catch (IOException expected) {
|
||||
assertTrue(expected.getMessage().contains("不允许访问")
|
||||
|| expected.getMessage().contains("无法解析"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void allowsPublicHost() throws Exception {
|
||||
PanBase.assertPublicHost(new URL("https://example.com/s/demo"));
|
||||
}
|
||||
}
|
||||
@@ -323,63 +323,6 @@ public class PanDomainTemplateTest {
|
||||
fsPattern.matcher("https://xxx.feishu.cn/docs/abc123").matches());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testYsPatternMatching() {
|
||||
Pattern ysPattern = PanDomainTemplate.YS.getPattern();
|
||||
|
||||
// 主域名
|
||||
Matcher m1 = ysPattern.matcher("https://qaiu.ysepan.com/");
|
||||
assertTrue("YS should match ysepan.com", m1.matches());
|
||||
assertEquals("qaiu", m1.group("KEY"));
|
||||
|
||||
Matcher m2 = ysPattern.matcher("http://sohehe4.ys168.com");
|
||||
assertTrue("YS should match ys168.com", m2.matches());
|
||||
assertEquals("sohehe4", m2.group("KEY"));
|
||||
|
||||
// 备用域名
|
||||
Matcher m3 = ysPattern.matcher("https://demo.cccpan.com/");
|
||||
assertTrue("YS should match cccpan.com", m3.matches());
|
||||
assertEquals("demo", m3.group("KEY"));
|
||||
|
||||
Matcher m4 = ysPattern.matcher("https://space.ysupan.com");
|
||||
assertTrue("YS should match ysupan.com", m4.matches());
|
||||
assertEquals("space", m4.group("KEY"));
|
||||
|
||||
Matcher m5 = ysPattern.matcher("https://user.uupan.net/");
|
||||
assertTrue("YS should match uupan.net", m5.matches());
|
||||
assertEquals("user", m5.group("KEY"));
|
||||
|
||||
Matcher m6 = ysPattern.matcher("https://ok.ysok.net");
|
||||
assertTrue("YS should match ysok.net", m6.matches());
|
||||
assertEquals("ok", m6.group("KEY"));
|
||||
|
||||
// 非空间子域 / 非白名单域名
|
||||
assertFalse("YS should NOT match www.ysepan.com",
|
||||
ysPattern.matcher("https://www.ysepan.com/").matches());
|
||||
assertFalse("YS should NOT match api host c6.ysepan.com",
|
||||
ysPattern.matcher("https://c6.ysepan.com/api/ml/mldq").matches());
|
||||
assertFalse("YS should NOT match CDN ys-c.ysepan.com",
|
||||
ysPattern.matcher("https://ys-c.ysepan.com/wap/qaiu/x").matches());
|
||||
assertFalse("YS should NOT match unrelated domain",
|
||||
ysPattern.matcher("https://qaiu.evil.com/").matches());
|
||||
assertFalse("YS should NOT match ysepan.com without space subdomain",
|
||||
ysPattern.matcher("https://ysepan.com/").matches());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testYsFromShareUrl() {
|
||||
ParserCreate parserCreate = ParserCreate.fromShareUrl("https://qaiu.ysepan.com/");
|
||||
ShareLinkInfo info = parserCreate.getShareLinkInfo();
|
||||
assertNotNull(info);
|
||||
assertEquals("ys", info.getType());
|
||||
assertEquals("永硕E盘", info.getPanName());
|
||||
assertEquals("qaiu", info.getShareKey());
|
||||
|
||||
ParserCreate backup = ParserCreate.fromShareUrl("https://demo.cccpan.com/");
|
||||
assertEquals("ys", backup.getShareLinkInfo().getType());
|
||||
assertEquals("demo", backup.getShareLinkInfo().getShareKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFsFromShareUrl() {
|
||||
// 测试文件链接解析
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package cn.qaiu.parser.impl;
|
||||
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import io.vertx.core.MultiMap;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Ye2Tool token 解析验证测试
|
||||
* 验证 Authorization 字段和 ******
|
||||
*/
|
||||
public class Ye2ToolTokenValidationTest {
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.out.println("========================================");
|
||||
System.out.println(" Ye2Tool Token 解析验证测试");
|
||||
System.out.println("========================================\n");
|
||||
|
||||
testNormalizeBearerToken();
|
||||
testYe2ToolWithTokenField();
|
||||
testYe2ToolWithAuthorizationField();
|
||||
testYe2ToolWithoutAuth();
|
||||
|
||||
System.out.println("\n========================================");
|
||||
System.out.println(" 所有验证通过! ✓");
|
||||
System.out.println("========================================");
|
||||
}
|
||||
|
||||
/** 独立验证 normalizeBearerToken 逻辑(内联实现与 Ye2Tool 保持一致)*/
|
||||
private static String normalizeBearerToken(String raw) {
|
||||
if (raw == null || raw.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String token = raw.trim();
|
||||
if (token.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
token = token.substring(7).trim();
|
||||
}
|
||||
return token.isEmpty() ? null : token;
|
||||
}
|
||||
|
||||
private static void testNormalizeBearerToken() {
|
||||
System.out.println("=== 验证 normalizeBearerToken 逻辑 ===");
|
||||
|
||||
assertNormalize("testtoken123abc", "testtoken123abc", "纯 token 不变");
|
||||
assertNormalize("Bearer testtoken123abc", "testtoken123abc", "******");
|
||||
assertNormalize("bearer testtoken123abc", "testtoken123abc", "小写 bearer 前缀被去掉");
|
||||
assertNormalize("BEARER testtoken123abc", "testtoken123abc", "大写 BEARER 前缀被去掉");
|
||||
assertNormalize(" Bearer testtoken123abc ", "testtoken123abc", "前后空格被 trim");
|
||||
assertNormalizeNull(null, "null 输入返回 null");
|
||||
assertNormalizeNull("", "空字符串返回 null");
|
||||
assertNormalizeNull(" ", "纯空格返回 null");
|
||||
|
||||
System.out.println();
|
||||
}
|
||||
|
||||
private static void assertNormalize(String input, String expected, String desc) {
|
||||
String actual = normalizeBearerToken(input);
|
||||
if (!expected.equals(actual)) {
|
||||
throw new AssertionError("FAIL [" + desc + "]: expected='" + expected + "', actual='" + actual + "'");
|
||||
}
|
||||
System.out.println("✓ " + desc);
|
||||
}
|
||||
|
||||
private static void assertNormalizeNull(String input, String desc) {
|
||||
String actual = normalizeBearerToken(input);
|
||||
if (actual != null) {
|
||||
throw new AssertionError("FAIL [" + desc + "]: expected null, actual='" + actual + "'");
|
||||
}
|
||||
System.out.println("✓ " + desc);
|
||||
}
|
||||
|
||||
private static void testYe2ToolWithTokenField() {
|
||||
System.out.println("=== 测试 Ye2Tool(token 字段)===");
|
||||
try {
|
||||
MultiMap auths = MultiMap.caseInsensitiveMultiMap();
|
||||
auths.set("token", "testtoken123abc");
|
||||
|
||||
Map<String, Object> otherParam = new HashMap<>();
|
||||
otherParam.put("auths", auths);
|
||||
|
||||
ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder()
|
||||
.type("YE")
|
||||
.panName("123网盘")
|
||||
.shareKey("test_key_token")
|
||||
.shareUrl("https://www.123pan.com/s/test123")
|
||||
.build();
|
||||
shareLinkInfo.setOtherParam(otherParam);
|
||||
|
||||
Ye2Tool ye2Tool = new Ye2Tool(shareLinkInfo);
|
||||
System.out.println("✓ Ye2Tool 实例创建成功(token 字段)\n");
|
||||
} catch (Exception e) {
|
||||
System.err.println("✗ Ye2Tool(token 字段)测试失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private static void testYe2ToolWithAuthorizationField() {
|
||||
System.out.println("=== 测试 Ye2Tool(Authorization 字段,带 Bearer)===");
|
||||
try {
|
||||
MultiMap auths = MultiMap.caseInsensitiveMultiMap();
|
||||
auths.set("Authorization", "Bearer testtoken123abc");
|
||||
|
||||
Map<String, Object> otherParam = new HashMap<>();
|
||||
otherParam.put("auths", auths);
|
||||
|
||||
ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder()
|
||||
.type("YE")
|
||||
.panName("123网盘")
|
||||
.shareKey("test_key_auth")
|
||||
.shareUrl("https://www.123pan.com/s/test456")
|
||||
.build();
|
||||
shareLinkInfo.setOtherParam(otherParam);
|
||||
|
||||
Ye2Tool ye2Tool = new Ye2Tool(shareLinkInfo);
|
||||
System.out.println("✓ Ye2Tool 实例创建成功(Authorization 字段)\n");
|
||||
} catch (Exception e) {
|
||||
System.err.println("✗ Ye2Tool(Authorization 字段)测试失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private static void testYe2ToolWithoutAuth() {
|
||||
System.out.println("=== 测试 Ye2Tool(无认证配置)===");
|
||||
try {
|
||||
ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder()
|
||||
.type("YE")
|
||||
.panName("123网盘")
|
||||
.shareKey("test_key_noauth")
|
||||
.shareUrl("https://www.123pan.com/s/test789")
|
||||
.build();
|
||||
|
||||
Ye2Tool ye2Tool = new Ye2Tool(shareLinkInfo);
|
||||
System.out.println("✓ Ye2Tool 实例创建成功(无认证)\n");
|
||||
} catch (Exception e) {
|
||||
System.err.println("✗ Ye2Tool(无认证)测试失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
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.buffer.Buffer;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.client.WebClient;
|
||||
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;
|
||||
private static WebClient webClient;
|
||||
|
||||
@BeforeClass
|
||||
public static void setUpClass() {
|
||||
vertx = Vertx.vertx();
|
||||
WebClientVertxInit.init(vertx);
|
||||
webClient = WebClient.create(vertx);
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void tearDownClass() {
|
||||
if (webClient != null) {
|
||||
webClient.close();
|
||||
}
|
||||
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"));
|
||||
|
||||
assertTrue(pattern.matcher("https://a.cccpan.com/").matches());
|
||||
assertTrue(pattern.matcher("https://a.ysupan.com").matches());
|
||||
assertTrue(pattern.matcher("https://a.uupan.net/").matches());
|
||||
assertTrue(pattern.matcher("https://a.ysok.net").matches());
|
||||
|
||||
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());
|
||||
assertFalse(pattern.matcher("https://qaiu.evil.com/").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(
|
||||
"yssl",
|
||||
"A95UIe495EkSKE",
|
||||
"Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC",
|
||||
"L",
|
||||
"lu20.jpg");
|
||||
// 与官方一致:xzpz 前不加 "_"
|
||||
assertEquals(
|
||||
"https://ys-l.ysepan.com/wap/yssl/A95UIe495EkSKE/Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC/lu20.jpg",
|
||||
url);
|
||||
assertFalse("force-download 前缀会导致 404", url.contains("/_"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testOfficialSampleUrlRealDownload() throws Exception {
|
||||
String official = "https://ys-l.ysepan.com/wap/yssl/A95UIe495EkSKE/Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC/lu20.jpg";
|
||||
String withForcePrefix = "https://ys-l.ysepan.com/wap/yssl/_A95UIe495EkSKE/Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC/lu20.jpg";
|
||||
|
||||
Buffer ok = download(official, "https://yssl.ysepan.com/");
|
||||
assertTrue("官方直链应能下载到 JPEG", ok.length() > 1000);
|
||||
assertEquals((byte) 0xFF, ok.getByte(0));
|
||||
assertEquals((byte) 0xD8, ok.getByte(1));
|
||||
|
||||
int forceStatus = webClient.getAbs(withForcePrefix)
|
||||
.putHeader("User-Agent", "Mozilla/5.0")
|
||||
.putHeader("Referer", "https://yssl.ysepan.com/")
|
||||
.send()
|
||||
.toCompletionStage().toCompletableFuture()
|
||||
.get(30, TimeUnit.SECONDS)
|
||||
.statusCode();
|
||||
assertNotEquals("带 _ 前缀的直链应失败(文件不存在)", 200, forceStatus);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testQaiuSpaceFileListAndRealDownload() 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);
|
||||
JsonObject paramJson = new JsonObject(CommonUtils.urlBase64Decode(param));
|
||||
assertFalse("downloadUrl 不应含 force 前缀",
|
||||
paramJson.getString("downloadUrl", "").contains("/_"));
|
||||
|
||||
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);
|
||||
assertFalse("解析直链不应含 _xzpz 前缀", downloadUrl.matches(".*/_[^/]+/.*"));
|
||||
assertTrue(downloadUrl.contains("ysepan.com"));
|
||||
|
||||
Buffer body = download(downloadUrl, "https://qaiu.ysepan.com/");
|
||||
assertEquals("真实下载大小应与列表一致", zip.getSize().longValue(), body.length());
|
||||
// ZIP magic: PK
|
||||
assertEquals('P', (char) body.getByte(0));
|
||||
assertEquals('K', (char) body.getByte(1));
|
||||
System.out.println("qaiu real download ok, url=" + downloadUrl + ", size=" + body.length());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFufu1ZmlHierarchyAndUrlItems() throws Exception {
|
||||
// https://fufu1.ysepan.com/ 无密码;游戏3 下应按 zml 展示子目录,再进子目录才是夸克/百度链接
|
||||
ParserCreate create = ParserCreate.fromShareUrl("https://fufu1.ysepan.com/");
|
||||
create.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
|
||||
|
||||
List<FileInfo> roots = create.createTool().parseFileList()
|
||||
.toCompletionStage().toCompletableFuture()
|
||||
.get(60, TimeUnit.SECONDS);
|
||||
assertNotNull(roots);
|
||||
FileInfo game3 = roots.stream()
|
||||
.filter(f -> "folder".equals(f.getFileType()))
|
||||
.filter(f -> "游戏3".equals(f.getFileName()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
assertNotNull("应有目录 游戏3", game3);
|
||||
|
||||
ParserCreate level2 = ParserCreate.fromShareUrl("https://fufu1.ysepan.com/");
|
||||
level2.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
|
||||
level2.getShareLinkInfo().getOtherParam().put("dirId", game3.getFileId());
|
||||
|
||||
List<FileInfo> subdirs = level2.createTool().parseFileList()
|
||||
.toCompletionStage().toCompletableFuture()
|
||||
.get(60, TimeUnit.SECONDS);
|
||||
assertNotNull(subdirs);
|
||||
assertTrue("游戏3 下应是子目录列表", subdirs.size() > 10);
|
||||
assertTrue("游戏3 下列表应全是 folder(zml 子目录)",
|
||||
subdirs.stream().allMatch(f -> "folder".equals(f.getFileType())));
|
||||
|
||||
FileInfo sample = subdirs.stream()
|
||||
.filter(f -> f.getFileName() != null && f.getFileName().contains("我是未来"))
|
||||
.findFirst()
|
||||
.orElse(subdirs.get(0));
|
||||
|
||||
// 从 parserUrl 提取 zml,或直接用 fileName
|
||||
ParserCreate level3 = ParserCreate.fromShareUrl("https://fufu1.ysepan.com/");
|
||||
level3.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
|
||||
level3.getShareLinkInfo().getOtherParam().put("dirId", game3.getFileId());
|
||||
level3.getShareLinkInfo().getOtherParam().put("zml", sample.getFileName());
|
||||
|
||||
List<FileInfo> links = level3.createTool().parseFileList()
|
||||
.toCompletionStage().toCompletableFuture()
|
||||
.get(60, TimeUnit.SECONDS);
|
||||
assertNotNull(links);
|
||||
assertFalse(links.isEmpty());
|
||||
assertTrue("子目录内应有 url 类型",
|
||||
links.stream().anyMatch(f -> "url".equals(f.getFileType())));
|
||||
assertTrue("应包含夸克/百度链接名",
|
||||
links.stream().anyMatch(f -> "夸克".equals(f.getFileName()) || "百度".equals(f.getFileName())));
|
||||
assertFalse("空占位 URL 不应出现",
|
||||
links.stream().anyMatch(f -> f.getFileName() == null || f.getFileName().isBlank()));
|
||||
assertTrue("URL 条目应带 previewUrl",
|
||||
links.stream().filter(f -> "url".equals(f.getFileType()))
|
||||
.allMatch(f -> f.getPreviewUrl() != null && f.getPreviewUrl().startsWith("http")));
|
||||
|
||||
System.out.println("fufu1 hierarchy ok: 游戏3 -> " + sample.getFileName()
|
||||
+ " -> " + links.stream().map(FileInfo::getFileName).toList());
|
||||
}
|
||||
|
||||
@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());
|
||||
|
||||
FileInfo file = files.stream()
|
||||
.filter(f -> "file".equals(f.getFileType()))
|
||||
.filter(f -> f.getSize() != null && f.getSize() > 0 && f.getSize() < 5_000_000)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (file != null) {
|
||||
String param = file.getParserUrl().substring(file.getParserUrl().lastIndexOf('/') + 1);
|
||||
JsonObject paramJson = new JsonObject(CommonUtils.urlBase64Decode(param));
|
||||
String downloadUrl = paramJson.getString("downloadUrl");
|
||||
assertNotNull(downloadUrl);
|
||||
assertFalse(downloadUrl.contains("/_"));
|
||||
|
||||
Buffer body = download(downloadUrl, "https://sohehe4.ysepan.com/");
|
||||
assertEquals(file.getSize().longValue(), body.length());
|
||||
System.out.println("sohehe4 real download ok, file=" + file.getFileName()
|
||||
+ ", size=" + body.length());
|
||||
} else {
|
||||
System.out.println("sohehe4 dir=" + dirId + " entries=" + files.size()
|
||||
+ " (no small file for real download sample)");
|
||||
}
|
||||
}
|
||||
|
||||
private static Buffer download(String url, String referer) throws Exception {
|
||||
return webClient.getAbs(url)
|
||||
.putHeader("User-Agent",
|
||||
"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")
|
||||
.putHeader("Referer", referer)
|
||||
.send()
|
||||
.toCompletionStage().toCompletableFuture()
|
||||
.thenApply(res -> {
|
||||
assertEquals("下载 HTTP 状态码应为 200: " + url, 200, res.statusCode());
|
||||
Buffer body = res.body();
|
||||
assertNotNull(body);
|
||||
assertTrue("下载内容为空: " + url, body.length() > 0);
|
||||
String ct = res.getHeader("Content-Type");
|
||||
assertFalse("不应返回 HTML 错误页: " + url,
|
||||
ct != null && ct.toLowerCase().contains("text/html"));
|
||||
return body;
|
||||
})
|
||||
.get(60, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package cn.qaiu.util;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.openjdk.nashorn.api.scripting.ScriptObjectMirror;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* 新版蓝奏页面会调用更多 document / jQuery API,沙箱必须能跑完并抓住 $.ajax。
|
||||
*/
|
||||
public class JsExecUtilsLzTest {
|
||||
|
||||
@Test
|
||||
public void testNewIframeAjaxWithDomApis() throws Exception {
|
||||
String js = """
|
||||
var lanosso = '';
|
||||
var down_1 = '';
|
||||
var wsk_sign = 'c20230908';
|
||||
var wp_sign = 'SIGN_ABC';
|
||||
var ajaxdata = 'MrBR';
|
||||
var kdns = 1;
|
||||
if (typeof(killdns)=='undefined'){
|
||||
var kdns = 0;
|
||||
}
|
||||
document.cookie = 'x=1';
|
||||
document.location.reload();
|
||||
document.querySelector('#tourl');
|
||||
document.createElement('div');
|
||||
$.ajax({
|
||||
type : 'post',
|
||||
url : '/ajaxfile.php?file=150233466',
|
||||
data : { 'action':'downprocess','websignkey':ajaxdata,'signs':ajaxdata,'sign':wp_sign,'websign':'','kd':kdns,'ves':1 },
|
||||
dataType : 'json',
|
||||
success:function(msg){
|
||||
$("#tourl").html("ok");
|
||||
$("#outime").css("display","block");
|
||||
}
|
||||
});
|
||||
""";
|
||||
ScriptObjectMirror sign = JsExecUtils.executeDynamicJs(js, null);
|
||||
assertNotNull(sign);
|
||||
assertEquals("/ajaxfile.php?file=150233466", String.valueOf(sign.get("url")));
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = (Map<String, Object>) sign.get("data");
|
||||
assertEquals("downprocess", String.valueOf(data.get("action")));
|
||||
assertEquals("SIGN_ABC", String.valueOf(data.get("sign")));
|
||||
assertEquals("MrBR", String.valueOf(data.get("websignkey")));
|
||||
assertEquals("1", String.valueOf(data.get("kd")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPwdViaJqueryValAndDocument() throws Exception {
|
||||
String js = """
|
||||
function down_p(){
|
||||
var pwd = $('#pwd').val();
|
||||
var pwd2 = document.getElementById('pwd').value;
|
||||
var pwd3 = document.querySelector('#pwd').value;
|
||||
$(".passwdinput").focus();
|
||||
$.ajax({
|
||||
type : 'post',
|
||||
url : '/ajaxm.php',
|
||||
data : { 'action':'downprocess','sign':'S1','p':pwd,'p2':pwd2,'p3':pwd3 }
|
||||
});
|
||||
}
|
||||
""";
|
||||
ScriptObjectMirror sign = JsExecUtils.executeDynamicJs(js, "down_p", "e4k4");
|
||||
assertNotNull(sign);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = (Map<String, Object>) sign.get("data");
|
||||
assertEquals("e4k4", String.valueOf(data.get("p")));
|
||||
assertEquals("e4k4", String.valueOf(data.get("p2")));
|
||||
assertEquals("e4k4", String.valueOf(data.get("p3")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReadyAndSuccessCallbackDoNotDropAjax() throws Exception {
|
||||
String js = """
|
||||
$(function(){
|
||||
document.getElementById('rpt').style.display = 'none';
|
||||
$.ajax({ url: '/ajaxm.php?file=1', data: { a: 1 } });
|
||||
$("#tourl").html("x");
|
||||
});
|
||||
""";
|
||||
ScriptObjectMirror sign = JsExecUtils.executeDynamicJs(js, null);
|
||||
assertNotNull(sign);
|
||||
assertTrue(String.valueOf(sign.get("url")).contains("ajaxm.php"));
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<revision>0.4.5</revision>
|
||||
<revision>0.3.4</revision>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
@@ -33,9 +33,9 @@
|
||||
<commons-lang3.version>3.18.0</commons-lang3.version>
|
||||
<commons-beanutils2.version>2.0.0</commons-beanutils2.version>
|
||||
<parserVersion>10.2.5</parserVersion>
|
||||
<jackson.version>2.18.9</jackson.version>
|
||||
<jackson.version>2.18.6</jackson.version>
|
||||
<!-- Logback 最新稳定版 -->
|
||||
<logback.version>1.5.34</logback.version>
|
||||
<logback.version>1.5.32</logback.version>
|
||||
<junit.version>4.13.2</junit.version>
|
||||
</properties>
|
||||
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
亿方云 (FangCloud) 分享链接有效性验证脚本
|
||||
|
||||
用法: 直接 `python3 verify_fangcloud_share.py` 运行, 分享链接已写死在 SHARE_URL
|
||||
里 (不走命令行传参), 要换链接就直接改这个常量。
|
||||
|
||||
根因(已用真实分享链接验证确认)
|
||||
--------------------------------
|
||||
GET https://v2.fangcloud.cn/apps/share_links/info/{uname}
|
||||
返回的是 200 + JSON, 结构形如:
|
||||
|
||||
{
|
||||
"process": {
|
||||
"is_closed": false,
|
||||
"is_expired": false,
|
||||
"item": {"type": "file", "id": 45006535173, "name": "..."},
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
FcTool.java 原来的实现判断有效性时读的是响应体**顶层**的 "is_valid" 字段
|
||||
(`json.getBoolean("is_valid")`), 但这个字段根本不存在于该接口的实际响应里
|
||||
(有效性信息实际上是 process.is_closed / process.is_expired, 且没有任何
|
||||
名叫 "is_valid" 的字段) —— 所以旧的判断逻辑其实从未真正读取过这个接口的
|
||||
有效性判断, 而是走的另一套 HTML 抓取 typed_id 的流程, 分享失效时会得到一个
|
||||
"未匹配到文件id(typed_id)" 这种令人困惑的技术报错, 而不是清晰的"分享已失效"。
|
||||
|
||||
修复方案(已同步到 FcTool.java): 解析前先请求一次
|
||||
https://v2.fangcloud.cn/apps/share_links/info/{uname}, 用
|
||||
process.is_closed / process.is_expired 判断分享是否有效, 无效则直接返回
|
||||
"分享已失效或不存在", 有效再继续走原来的 HTML + files/download 流程。
|
||||
|
||||
本脚本用于本地复现/回归验证这条判断逻辑, 并顺带跑一遍完整的取直链流程。
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import requests
|
||||
|
||||
# 直接写死分享链接, 不通过命令行参数传入
|
||||
SHARE_URL = "https://v2.fangcloud.cn/h5/share/ded6dc6b9c3672b40b769804bf"
|
||||
|
||||
# 如果分享有密码, 在这里填上, 没有就留空字符串
|
||||
SHARE_PASSWORD = ""
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "application/json, text/html;q=0.9, */*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
}
|
||||
|
||||
# 修复后的正则, 额外兼容 /h5/share/ 移动端落地页路径 (对齐 PanDomainTemplate.FC)
|
||||
FC_REGEX = re.compile(r"https://v2\.fangcloud\.(com|cn)/(?:h5/)?(s|share|sharing)/(?P<KEY>.+)")
|
||||
|
||||
SHARE_INFO_URL = "https://v2.fangcloud.cn/apps/share_links/info/{key}"
|
||||
SHARE_URL_PREFIX = "https://v2.fangcloud.com/sharing/{key}"
|
||||
DOWN_REQUEST_URL = "https://v2.fangcloud.cn/apps/files/download"
|
||||
|
||||
TYPED_ID_RE = re.compile(r'id="typed_id"\s+value="file_(\d+)"')
|
||||
|
||||
|
||||
def extract_share_key(url: str) -> str:
|
||||
m = FC_REGEX.search(url)
|
||||
if not m:
|
||||
raise ValueError(f"无法从链接中提取 shareKey (正则不匹配): {url}")
|
||||
return m.group("KEY")
|
||||
|
||||
|
||||
def check_is_valid(share_key: str, session: requests.Session) -> bool:
|
||||
print("=" * 70)
|
||||
print("Step 1. 请求 share_links/info 判断分享有效性 (对齐 FcTool.java 修复后的逻辑)")
|
||||
print("=" * 70)
|
||||
url = SHARE_INFO_URL.format(key=share_key)
|
||||
r = session.get(url, timeout=15)
|
||||
print(f"GET {url}")
|
||||
print(f"状态码: {r.status_code}")
|
||||
try:
|
||||
data = r.json()
|
||||
except Exception:
|
||||
print("响应不是合法JSON => 判定分享已失效或不存在\n")
|
||||
return False
|
||||
|
||||
process = data.get("process")
|
||||
if not process:
|
||||
print("响应中没有 process 字段 => 判定分享已失效或不存在\n")
|
||||
return False
|
||||
|
||||
is_closed = bool(process.get("is_closed"))
|
||||
is_expired = bool(process.get("is_expired"))
|
||||
item = process.get("item")
|
||||
print(f"is_closed = {is_closed}, is_expired = {is_expired}, item = {item}")
|
||||
|
||||
if is_closed or is_expired:
|
||||
print("=> 分享已失效或不存在\n")
|
||||
return False
|
||||
|
||||
print("=> 分享有效\n")
|
||||
return True
|
||||
|
||||
|
||||
def get_download_url(share_key: str, pwd: str, session: requests.Session) -> str:
|
||||
print("=" * 70)
|
||||
print("Step 2. 走原有 HTML + files/download 流程取直链")
|
||||
print("=" * 70)
|
||||
r = session.get(SHARE_URL_PREFIX.format(key=share_key), timeout=15)
|
||||
print(f"GET {SHARE_URL_PREFIX.format(key=share_key)} -> {r.status_code}, 最终URL: {r.url}")
|
||||
|
||||
html = r.text
|
||||
if pwd:
|
||||
# 加密分享: 提交密码换取跳转后的落地页 (此处仅示意, 具体见 FcTool.java)
|
||||
m = re.search(r'name="requesttoken"\s+value="([a-zA-Z0-9_+=]+)"', html)
|
||||
if not m:
|
||||
raise RuntimeError("未匹配到加密分享的密码输入页面的 requesttoken")
|
||||
token = m.group(1)
|
||||
r2 = session.post(
|
||||
"https://v2.fangcloud.cn/sharing/" + share_key,
|
||||
data={"requesttoken": token, "password": pwd},
|
||||
timeout=15,
|
||||
)
|
||||
html = r2.text
|
||||
|
||||
m = TYPED_ID_RE.search(html)
|
||||
if not m:
|
||||
raise RuntimeError("未匹配到文件id(typed_id), 分享可能是文件夹或页面结构有变化")
|
||||
fid = m.group(1)
|
||||
print(f"提取到 file_id = {fid}")
|
||||
|
||||
r3 = session.get(
|
||||
DOWN_REQUEST_URL,
|
||||
params={"file_id": fid, "scenario": "share", "unique_name": share_key},
|
||||
timeout=15,
|
||||
allow_redirects=False,
|
||||
)
|
||||
print(f"GET {DOWN_REQUEST_URL} -> {r3.status_code}")
|
||||
if r3.status_code in (301, 302) and r3.headers.get("Location"):
|
||||
return r3.headers["Location"]
|
||||
data = r3.json()
|
||||
if not data.get("success"):
|
||||
raise RuntimeError(f"取直链失败: {data}")
|
||||
return data["download_url"]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
share_key = extract_share_key(SHARE_URL)
|
||||
print(f"分享链接: {SHARE_URL}")
|
||||
print(f"提取到的 shareKey: {share_key}\n")
|
||||
|
||||
session = requests.Session()
|
||||
session.headers.update(HEADERS)
|
||||
|
||||
if not check_is_valid(share_key, session):
|
||||
print("结论: 分享无效, 不再继续取直链。")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
url = get_download_url(share_key, SHARE_PASSWORD, session)
|
||||
print(f"\n>>> 直链: {url}")
|
||||
except Exception as e:
|
||||
print(f"\n取直链失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nfd-web",
|
||||
"version": "0.4.5",
|
||||
"version": "0.2.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.18.0",
|
||||
"axios": "1.16.1",
|
||||
"clipboard": "^2.0.11",
|
||||
"core-js": "^3.8.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
:class="[getFileTypeClass(file), { 'selected': batchMode && isFileSelected(file) }]"
|
||||
@click="batchMode ? onBatchClick(file) : handleFileClick(file)"
|
||||
>
|
||||
<div v-if="batchMode && isDownloadableFile(file)" class="batch-checkbox" @click.stop="toggleFileSelect(file)">
|
||||
<div v-if="batchMode && file.fileType !== 'folder'" class="batch-checkbox" @click.stop="toggleFileSelect(file)">
|
||||
<i :class="isFileSelected(file) ? 'fas fa-check-square' : 'far fa-square'"
|
||||
:style="{ color: isFileSelected(file) ? '#409eff' : '#c0c4cc' }"></i>
|
||||
</div>
|
||||
@@ -52,10 +52,9 @@
|
||||
<div class="batch-right">
|
||||
<el-button
|
||||
type="primary" size="small"
|
||||
:disabled="selectedFiles.length === 0 || batchBrowserDownloadDisabled"
|
||||
:disabled="selectedFiles.length === 0"
|
||||
:loading="batchDownloading"
|
||||
@click="batchBrowserDownload"
|
||||
:title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''"
|
||||
>
|
||||
<i class="fas fa-download"></i> 浏览器下载
|
||||
</el-button>
|
||||
@@ -128,10 +127,9 @@
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="selectedFiles.length === 0 || batchDownloading || batchBrowserDownloadDisabled"
|
||||
:disabled="selectedFiles.length === 0 || batchDownloading"
|
||||
:loading="batchDownloading"
|
||||
@click="batchBrowserDownload"
|
||||
:title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''"
|
||||
>
|
||||
浏览器下载
|
||||
</el-button>
|
||||
@@ -168,41 +166,25 @@
|
||||
</div>
|
||||
<h4 class="file-detail-name">{{ selectedNode.fileName }}</h4>
|
||||
<div v-if="selectedNode.fileType !== 'folder'" class="file-detail-meta">
|
||||
<p>类型: {{ selectedNode.fileType === 'url' ? '超链接' : getFileTypeClass(selectedNode) }}</p>
|
||||
<p v-if="selectedNode.fileType !== 'url'">大小: {{ selectedNode.sizeStr || '0B' }}</p>
|
||||
<p v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl" class="file-detail-link">
|
||||
链接: {{ selectedNode.previewUrl }}
|
||||
</p>
|
||||
<p>类型: {{ getFileTypeClass(selectedNode) }}</p>
|
||||
<p>大小: {{ selectedNode.sizeStr || '0B' }}</p>
|
||||
<p v-if="formatDate(selectedNode.createTime)">创建时间: {{ formatDate(selectedNode.createTime) }}</p>
|
||||
<p v-if="formatDate(selectedNode.updateTime)">更新时间: {{ formatDate(selectedNode.updateTime) }}</p>
|
||||
</div>
|
||||
<div class="file-detail-actions">
|
||||
<el-button
|
||||
v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl"
|
||||
type="primary" size="small"
|
||||
@click="openExternalLink(selectedNode)"
|
||||
>
|
||||
<i class="fas fa-external-link-alt"></i> 打开链接
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="selectedNode.parserUrl || selectedNode.previewUrl"
|
||||
size="small"
|
||||
@click="previewFile(selectedNode)"
|
||||
>
|
||||
<el-button v-if="selectedNode.parserUrl" size="small" @click="previewFile(selectedNode)">
|
||||
<i class="fas fa-external-link-alt"></i> 打开
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDownloadableFile(selectedNode)"
|
||||
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
|
||||
type="success" size="small"
|
||||
@click="handleDownload(selectedNode)"
|
||||
:loading="downloadLoading"
|
||||
:disabled="needsDownloader(selectedNode)"
|
||||
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器下载' : ''"
|
||||
>
|
||||
<i class="fas fa-download"></i> 下载
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDownloadableFile(selectedNode)"
|
||||
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
|
||||
type="primary" size="small"
|
||||
@click="sendSingleToDownloader(selectedNode)"
|
||||
:loading="singleSendLoading"
|
||||
@@ -210,12 +192,10 @@
|
||||
<i class="fas fa-paper-plane"></i> 发送到下载器
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDownloadableFile(selectedNode)"
|
||||
v-if="selectedNode.parserUrl"
|
||||
size="small"
|
||||
@click="copyDirectLink(selectedNode)"
|
||||
:loading="copyLinkLoading"
|
||||
:disabled="needsDownloader(selectedNode)"
|
||||
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器,无法直接复制直链' : ''"
|
||||
>
|
||||
<i class="fas fa-link"></i> 复制直链
|
||||
</el-button>
|
||||
@@ -263,41 +243,25 @@
|
||||
</div>
|
||||
<h4 class="file-detail-name">{{ selectedNode.fileName }}</h4>
|
||||
<div v-if="selectedNode.fileType !== 'folder'" class="file-detail-meta">
|
||||
<p>类型: {{ selectedNode.fileType === 'url' ? '超链接' : getFileTypeClass(selectedNode) }}</p>
|
||||
<p v-if="selectedNode.fileType !== 'url'">大小: {{ selectedNode.sizeStr || '0B' }}</p>
|
||||
<p v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl" class="file-detail-link">
|
||||
链接: {{ selectedNode.previewUrl }}
|
||||
</p>
|
||||
<p>类型: {{ getFileTypeClass(selectedNode) }}</p>
|
||||
<p>大小: {{ selectedNode.sizeStr || '0B' }}</p>
|
||||
<p v-if="formatDate(selectedNode.createTime)">创建时间: {{ formatDate(selectedNode.createTime) }}</p>
|
||||
<p v-if="formatDate(selectedNode.updateTime)">更新时间: {{ formatDate(selectedNode.updateTime) }}</p>
|
||||
</div>
|
||||
<div class="file-detail-actions">
|
||||
<el-button
|
||||
v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl"
|
||||
type="primary" size="small"
|
||||
@click="openExternalLink(selectedNode)"
|
||||
>
|
||||
<i class="fas fa-external-link-alt"></i> 打开链接
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="selectedNode.parserUrl || selectedNode.previewUrl"
|
||||
size="small"
|
||||
@click="previewFile(selectedNode)"
|
||||
>
|
||||
<el-button v-if="selectedNode.parserUrl" size="small" @click="previewFile(selectedNode)">
|
||||
<i class="fas fa-external-link-alt"></i> 打开
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDownloadableFile(selectedNode)"
|
||||
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
|
||||
type="success" size="small"
|
||||
@click="handleDownload(selectedNode)"
|
||||
:loading="downloadLoading"
|
||||
:disabled="needsDownloader(selectedNode)"
|
||||
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器下载' : ''"
|
||||
>
|
||||
<i class="fas fa-download"></i> 下载
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDownloadableFile(selectedNode)"
|
||||
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
|
||||
type="primary" size="small"
|
||||
@click="sendSingleToDownloader(selectedNode)"
|
||||
:loading="singleSendLoading"
|
||||
@@ -305,12 +269,10 @@
|
||||
<i class="fas fa-paper-plane"></i> 发送到下载器
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDownloadableFile(selectedNode)"
|
||||
v-if="selectedNode.parserUrl"
|
||||
size="small"
|
||||
@click="copyDirectLink(selectedNode)"
|
||||
:loading="copyLinkLoading"
|
||||
:disabled="needsDownloader(selectedNode)"
|
||||
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器,无法直接复制直链' : ''"
|
||||
>
|
||||
<i class="fas fa-link"></i> 复制直链
|
||||
</el-button>
|
||||
@@ -328,7 +290,7 @@
|
||||
<div v-if="batchMode" class="mobile-batch-footer">
|
||||
<span class="tree-sidebar-count">已勾选 {{ selectedFiles.length }} 个文件</span>
|
||||
<div class="tree-sidebar-actions">
|
||||
<el-button type="primary" size="small" :disabled="selectedFiles.length === 0 || batchDownloading || batchBrowserDownloadDisabled" :loading="batchDownloading" @click="batchBrowserDownload" :title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''">浏览器下载</el-button>
|
||||
<el-button type="primary" size="small" :disabled="selectedFiles.length === 0 || batchDownloading" :loading="batchDownloading" @click="batchBrowserDownload">浏览器下载</el-button>
|
||||
<el-button type="success" size="small" :disabled="selectedFiles.length === 0 || batchDownloading" :loading="batchDownloading" @click="batchSendToDownloader">发送到下载器</el-button>
|
||||
<el-button size="small" @click="toggleBatchMode">取消</el-button>
|
||||
</div>
|
||||
@@ -361,22 +323,19 @@
|
||||
</div>
|
||||
|
||||
<span slot="footer" class="dialog-footer">
|
||||
<el-button type="primary" @click="previewFile(selectedFile)">
|
||||
{{ selectedFile?.fileType === 'url' ? '打开链接' : '打开' }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="previewFile(selectedFile)">打开</el-button>
|
||||
<!-- 弹窗下载按钮 -->
|
||||
<el-button
|
||||
v-if="isDownloadableFile(selectedFile)"
|
||||
v-if="selectedFile && selectedFile.parserUrl"
|
||||
type="success"
|
||||
@click="handleDownload(selectedFile)"
|
||||
style="margin-left: 8px;"
|
||||
:loading="downloadLoading"
|
||||
:disabled="needsDownloader(selectedFile)"
|
||||
:title="needsDownloader(selectedFile) ? '该网盘需使用下载器下载' : ''"
|
||||
>
|
||||
下载
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDownloadableFile(selectedFile)"
|
||||
v-if="selectedFile && selectedFile.parserUrl"
|
||||
type="primary"
|
||||
@click="sendSingleToDownloader(selectedFile)"
|
||||
style="margin-left: 8px;"
|
||||
@@ -385,12 +344,10 @@
|
||||
发送到下载器
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDownloadableFile(selectedFile)"
|
||||
v-if="selectedFile && selectedFile.parserUrl"
|
||||
@click="copyDirectLink(selectedFile)"
|
||||
style="margin-left: 8px;"
|
||||
:loading="copyLinkLoading"
|
||||
:disabled="needsDownloader(selectedFile)"
|
||||
:title="needsDownloader(selectedFile) ? '该网盘需使用下载器,无法直接复制直链' : ''"
|
||||
>
|
||||
复制直链
|
||||
</el-button>
|
||||
@@ -437,11 +394,6 @@ export default {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 加密 auth,用于子目录/下载请求透传(后端未带上时前端补齐)
|
||||
auth: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
viewMode: {
|
||||
type: String,
|
||||
default: 'pane' // 'pane' or 'tree'
|
||||
@@ -508,11 +460,6 @@ export default {
|
||||
lines.push(`更新时间: ${updateTime}`)
|
||||
}
|
||||
return lines
|
||||
},
|
||||
// 所选文件全部需要下载器时,禁用浏览器批量下载
|
||||
batchBrowserDownloadDisabled() {
|
||||
return this.selectedFiles.length > 0
|
||||
&& this.selectedFiles.every(f => this.needsDownloader(f))
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -546,13 +493,6 @@ export default {
|
||||
}
|
||||
return headers
|
||||
},
|
||||
// 子目录/下载链接透传 auth,避免进入子目录变成 guest
|
||||
// 注意:auth 可能已 encodeURIComponent,直接拼接,避免 searchParams 再编一层
|
||||
withAuth(url) {
|
||||
if (!url || !this.auth) return url
|
||||
if (/[?&]auth=/.test(url)) return url
|
||||
return url + (url.includes('?') ? '&' : '?') + 'auth=' + this.auth
|
||||
},
|
||||
buildApiUrl() {
|
||||
const baseUrl = `${window.location.origin}/v2/getFileList`
|
||||
const params = new URLSearchParams({
|
||||
@@ -561,14 +501,14 @@ export default {
|
||||
if (this.password) {
|
||||
params.append('pwd', this.password)
|
||||
}
|
||||
return this.withAuth(`${baseUrl}?${params.toString()}`)
|
||||
return `${baseUrl}?${params.toString()}`
|
||||
},
|
||||
// 懒加载子节点
|
||||
loadNode(node, resolve) {
|
||||
if (node.level === 0) {
|
||||
resolve(this.treeData[0].children)
|
||||
} else if (node.data.fileType === 'folder' && node.data.parserUrl) {
|
||||
axios.get(this.withAuth(node.data.parserUrl), { headers: this.apiKeyHeaders() }).then(res => {
|
||||
axios.get(node.data.parserUrl, { headers: this.apiKeyHeaders() }).then(res => {
|
||||
if (res.data.code === 200) {
|
||||
const children = (res.data.data || []).map(item => ({
|
||||
...item,
|
||||
@@ -595,8 +535,6 @@ export default {
|
||||
handleFileClick(file) {
|
||||
if (file.fileType === 'folder') {
|
||||
this.enterFolder(file)
|
||||
} else if (file.fileType === 'url') {
|
||||
this.openExternalLink(file)
|
||||
} else if (this.viewMode === 'pane') {
|
||||
this.selectedFile = file
|
||||
this.fileDialogVisible = true
|
||||
@@ -610,11 +548,10 @@ export default {
|
||||
}
|
||||
try {
|
||||
this.loading = true
|
||||
const folderUrl = this.withAuth(folder.parserUrl)
|
||||
const response = await axios.get(folderUrl, { headers: this.apiKeyHeaders() })
|
||||
const response = await axios.get(folder.parserUrl, { headers: this.apiKeyHeaders() })
|
||||
if (response.data.code === 200) {
|
||||
const newDir = {
|
||||
url: folderUrl,
|
||||
url: folder.parserUrl,
|
||||
name: folder.fileName || '未命名文件夹'
|
||||
}
|
||||
this.pathStack.push(newDir)
|
||||
@@ -648,7 +585,7 @@ export default {
|
||||
}
|
||||
try {
|
||||
this.loading = true
|
||||
const response = await axios.get(this.withAuth(currentDir.url), { headers: this.apiKeyHeaders() })
|
||||
const response = await axios.get(currentDir.url, { headers: this.apiKeyHeaders() })
|
||||
if (response.data.code === 200) {
|
||||
this.currentFileList = response.data.data || []
|
||||
} else {
|
||||
@@ -662,32 +599,8 @@ export default {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
isDownloadableFile(file) {
|
||||
return !!(file && file.parserUrl && file.fileType !== 'folder' && file.fileType !== 'url')
|
||||
},
|
||||
// 需要下载器(带 cookie 等特殊头)时,浏览器直连/复制直链不可用
|
||||
// UC/夸克目录文件始终走下载器
|
||||
needsDownloader(file) {
|
||||
if (!file) return false
|
||||
if (file.extParameters && file.extParameters.needDownloader) return true
|
||||
const pan = (file.panType || '').toLowerCase()
|
||||
return pan === 'uc' || pan === 'qk'
|
||||
},
|
||||
openExternalLink(file) {
|
||||
const link = file?.previewUrl || file?.description
|
||||
if (!link) {
|
||||
this.$message.warning('该条目暂无外链')
|
||||
return
|
||||
}
|
||||
window.open(link, '_blank', 'noopener,noreferrer')
|
||||
this.closeFileDialog()
|
||||
},
|
||||
// 预览文件
|
||||
previewFile(file) {
|
||||
if (file?.fileType === 'url') {
|
||||
this.openExternalLink(file)
|
||||
return
|
||||
}
|
||||
if (file?.previewUrl || file?.parserUrl) {
|
||||
this.previewUrl = this.appendToken(file.previewUrl || file.parserUrl)
|
||||
this.isPreviewing = true
|
||||
@@ -737,18 +650,25 @@ export default {
|
||||
// 需要下载器,调用 getFileDownInfo 接口获取下载信息
|
||||
this.downloadLoading = true
|
||||
try {
|
||||
const tp = this.extractTypeParam(file)
|
||||
if (!tp) {
|
||||
// 从 parserUrl 提取 type 和 param
|
||||
// parserUrl 格式: /v2/redirectUrl/{type}/{param} 或 完整URL
|
||||
const url = new URL(file.parserUrl, window.location.origin)
|
||||
const pathParts = url.pathname.split('/')
|
||||
// 找到 redirectUrl 后面的部分
|
||||
const redirectIdx = pathParts.indexOf('redirectUrl')
|
||||
if (redirectIdx === -1 || redirectIdx + 2 >= pathParts.length) {
|
||||
this.$message.error('无法解析下载参数')
|
||||
return
|
||||
}
|
||||
const type = pathParts[redirectIdx + 1]
|
||||
const param = pathParts[redirectIdx + 2]
|
||||
|
||||
const headers = {}
|
||||
const apiKey = localStorage.getItem('nfd_user_api_key')
|
||||
if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey
|
||||
}
|
||||
const response = await axios.get(this.buildFileDownInfoUrl(tp.type, tp.param), { headers })
|
||||
const response = await axios.get(`${window.location.origin}/v2/getFileDownInfo/${type}/${param}`, { headers })
|
||||
if (response.data.code === 200 && response.data.data) {
|
||||
const info = response.data.data
|
||||
if (info.needDownloader) {
|
||||
@@ -839,7 +759,10 @@ export default {
|
||||
const tp = this.extractTypeParam(file)
|
||||
if (tp && file.extParameters && file.extParameters.needDownloader) {
|
||||
const headers = this.apiKeyHeaders()
|
||||
const resp = await axios.get(this.buildFileDownInfoUrl(tp.type, tp.param), { headers })
|
||||
const resp = await axios.get(
|
||||
`${window.location.origin}/v2/getFileDownInfo/${tp.type}/${tp.param}`,
|
||||
{ headers }
|
||||
)
|
||||
const info = resp.data.data || resp.data
|
||||
if (info && info.downloadUrl) {
|
||||
await addDownload(info.downloadUrl, info.downloadHeaders || {}, file.fileName)
|
||||
@@ -947,9 +870,7 @@ export default {
|
||||
},
|
||||
fileMetaText(file) {
|
||||
const parts = []
|
||||
if (file.fileType === 'url') {
|
||||
parts.push('超链接')
|
||||
} else if (file.fileType !== 'folder') {
|
||||
if (file.fileType !== 'folder') {
|
||||
parts.push(file.sizeStr || '0B')
|
||||
}
|
||||
const timeText = this.formatDate(file.createTime)
|
||||
@@ -995,7 +916,7 @@ export default {
|
||||
this.toggleFileSelect(file)
|
||||
},
|
||||
selectAll() {
|
||||
this.selectedFiles = this.currentFileList.filter(f => this.isDownloadableFile(f))
|
||||
this.selectedFiles = this.currentFileList.filter(f => f.fileType !== 'folder' && f.parserUrl)
|
||||
},
|
||||
deselectAll() {
|
||||
this.selectedFiles = []
|
||||
@@ -1003,41 +924,25 @@ export default {
|
||||
onTreeCheckChange() {
|
||||
if (!this.$refs.fileTree) return
|
||||
const checked = this.$refs.fileTree.getCheckedNodes()
|
||||
this.selectedFiles = checked.filter(n => this.isDownloadableFile(n))
|
||||
this.selectedFiles = checked.filter(n => n.fileType !== 'folder' && n.parserUrl)
|
||||
},
|
||||
extractTypeParam(file) {
|
||||
if (!file.parserUrl) return null
|
||||
try {
|
||||
// pathname 已是解码后的 path;后端现用 URL-Safe Base64(无 %),可直接使用
|
||||
const url = new URL(file.parserUrl, window.location.origin)
|
||||
const m = url.pathname.match(/\/redirectUrl\/([^/]+)\/(.+)$/)
|
||||
if (!m) return null
|
||||
let param = m[2]
|
||||
// 兼容历史「标准 Base64 + URLEncode」旧链接
|
||||
if (/%[0-9A-Fa-f]{2}/.test(param)) {
|
||||
try { param = decodeURIComponent(param) } catch { /* ignore */ }
|
||||
}
|
||||
return { type: m[1], param }
|
||||
const parts = url.pathname.split('/')
|
||||
const idx = parts.indexOf('redirectUrl')
|
||||
if (idx === -1 || idx + 2 >= parts.length) return null
|
||||
return { type: parts[idx + 1], param: parts[idx + 2] }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
// URL-Safe Base64 本身可进 path;encodeURIComponent 对 - _ 无影响,只 encode 一次
|
||||
buildFileDownInfoUrl(type, param) {
|
||||
return this.withAuth(
|
||||
`${window.location.origin}/v2/getFileDownInfo/${type}/${encodeURIComponent(param)}`
|
||||
)
|
||||
},
|
||||
async batchBrowserDownload() {
|
||||
if (this.selectedFiles.length === 0) return
|
||||
const files = this.selectedFiles.filter(f => !this.needsDownloader(f))
|
||||
if (files.length === 0) {
|
||||
this.$message.warning('所选文件需使用「发送到下载器」下载')
|
||||
return
|
||||
}
|
||||
this.batchDownloading = true
|
||||
this.batchProgress = { current: 0, total: files.length, failed: 0 }
|
||||
for (const file of files) {
|
||||
this.batchProgress = { current: 0, total: this.selectedFiles.length, failed: 0 }
|
||||
for (const file of this.selectedFiles) {
|
||||
try {
|
||||
const a = document.createElement('a')
|
||||
const rawUrl = file.parserUrl.startsWith('http') ? file.parserUrl : (window.location.origin + file.parserUrl)
|
||||
@@ -1070,32 +975,13 @@ export default {
|
||||
const total = this.selectedFiles.length
|
||||
this.batchProgress = { current: 0, total, failed: 0 }
|
||||
|
||||
// needDownloader 文件走 getFileDownInfo 拿直链+cookie;其余发 parserUrl + headers
|
||||
// 所有文件统一发 parserUrl(302) + downloadHeaders 给下载器
|
||||
const downloadTasks = []
|
||||
const apiHeaders = this.apiKeyHeaders()
|
||||
for (const file of this.selectedFiles) {
|
||||
try {
|
||||
if (this.needsDownloader(file)) {
|
||||
const tp = this.extractTypeParam(file)
|
||||
if (!tp) throw new Error('无法解析下载参数')
|
||||
const resp = await axios.get(this.buildFileDownInfoUrl(tp.type, tp.param), { headers: apiHeaders })
|
||||
const info = resp.data.data || resp.data
|
||||
if (!info?.downloadUrl) throw new Error('获取下载信息失败')
|
||||
downloadTasks.push({
|
||||
url: info.downloadUrl,
|
||||
headers: info.downloadHeaders || {},
|
||||
fileName: file.fileName
|
||||
})
|
||||
} else {
|
||||
const rawUrl = file.parserUrl.startsWith('http') ? file.parserUrl : (window.location.origin + file.parserUrl)
|
||||
const url = this.appendToken(rawUrl)
|
||||
const headers = (file.extParameters && file.extParameters.downloadHeaders) || {}
|
||||
downloadTasks.push({ url, headers, fileName: file.fileName })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('准备下载任务失败:', file.fileName, e)
|
||||
this.batchProgress.failed++
|
||||
}
|
||||
const rawUrl = file.parserUrl.startsWith('http') ? file.parserUrl : (window.location.origin + file.parserUrl)
|
||||
const url = this.appendToken(rawUrl)
|
||||
const headers = (file.extParameters && file.extParameters.downloadHeaders) || {}
|
||||
downloadTasks.push({ url, headers, fileName: file.fileName })
|
||||
this.batchProgress.current++
|
||||
}
|
||||
|
||||
@@ -1119,18 +1005,14 @@ export default {
|
||||
},
|
||||
renderContent(h, { node, data, store }) {
|
||||
const isFolder = data.fileType === 'folder'
|
||||
const isUrl = data.fileType === 'url'
|
||||
return h('div', {
|
||||
class: 'custom-tree-node'
|
||||
}, [
|
||||
h('i', {
|
||||
class: [
|
||||
this.getFileIcon(data),
|
||||
{ 'folder-icon': isFolder, 'url-icon': isUrl, 'file-icon': !isFolder && !isUrl }
|
||||
]
|
||||
class: [this.getFileIcon(data), { 'folder-icon': isFolder, 'file-icon': !isFolder }]
|
||||
}),
|
||||
h('span', {
|
||||
class: ['node-label', { 'folder-text': isFolder, 'url-text': isUrl, 'file-text': !isFolder && !isUrl }]
|
||||
class: ['node-label', { 'folder-text': isFolder, 'file-text': !isFolder }]
|
||||
}, node.label)
|
||||
])
|
||||
}
|
||||
@@ -1311,10 +1193,6 @@ html, body, #app, .main-container, .directory-tree, .content-card {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.url .file-icon {
|
||||
color: #1a73e8;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
@@ -1560,22 +1438,6 @@ html, body, #app, .main-container, .directory-tree, .content-card {
|
||||
color: #4a9eff !important;
|
||||
}
|
||||
|
||||
.custom-tree-node .url-icon {
|
||||
color: #1a73e8 !important;
|
||||
}
|
||||
|
||||
.dark-theme .custom-tree-node .url-icon {
|
||||
color: #8ab4f8 !important;
|
||||
}
|
||||
|
||||
.custom-tree-node .url-text {
|
||||
color: #1a73e8 !important;
|
||||
}
|
||||
|
||||
.dark-theme .custom-tree-node .url-text {
|
||||
color: #8ab4f8 !important;
|
||||
}
|
||||
|
||||
.custom-tree-node .folder-text {
|
||||
color: #409eff !important;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -356,13 +356,6 @@
|
||||
host: /(fast|drive)\.uc\.cn/,
|
||||
name: 'UC网盘'
|
||||
},
|
||||
// 永硕E盘:主 ysepan.com/ys168.com,备 cccpan.com/ysupan.com/uupan.net/ysok.net
|
||||
ysepan: {
|
||||
reg: /https?:\/\/(?!(?:www|zy|ht|api|c\d+|ys-[a-zA-Z0-9]+)\.)[a-zA-Z\d-]+\.(?:ysepan\.com|ys168\.com|cccpan\.com|ysupan\.com|uupan\.net|ysok\.net)\/?/,
|
||||
host: /[a-zA-Z\d-]+\.(?:ysepan\.com|ys168\.com|cccpan\.com|ysupan\.com|uupan\.net|ysok\.net)/,
|
||||
name: '永硕E盘',
|
||||
storage: 'hash'
|
||||
},
|
||||
|
||||
other: {
|
||||
reg: /https:\/\/([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}\/s\/.+/,
|
||||
|
||||
@@ -5,7 +5,6 @@ const fileTypeUtils = {
|
||||
},
|
||||
getFileTypeClass(file) {
|
||||
if (file.fileType === 'folder') return 'folder'
|
||||
if (file.fileType === 'url') return 'url'
|
||||
const ext = this.getFileExtension(file.fileName)
|
||||
const fileTypes = {
|
||||
'image': ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'],
|
||||
@@ -24,8 +23,6 @@ const fileTypeUtils = {
|
||||
},
|
||||
getFileIcon(file) {
|
||||
if (file.fileType === 'folder') return 'fas fa-folder'
|
||||
// 永硕等网盘的外链/公告条目
|
||||
if (file.fileType === 'url') return 'fas fa-link'
|
||||
const ext = this.getFileExtension(file.fileName)
|
||||
const iconMap = {
|
||||
'jpg': 'fas fa-file-image', 'jpeg': 'fas fa-file-image', 'png': 'fas fa-file-image',
|
||||
|
||||
@@ -131,15 +131,10 @@
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<span>下载链接</span>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<el-tooltip :disabled="!needsDownloader"
|
||||
content="该网盘需使用下载器下载" placement="top">
|
||||
<el-button @click="openUrl(downloadUrl)" type="primary" size="small"
|
||||
:disabled="needsDownloader">
|
||||
<el-icon style="margin-right: 4px;"><Download /></el-icon> 下载
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-button @click="openUrl(getPreviewLink())" type="default" size="small"
|
||||
:disabled="needsDownloader">
|
||||
<el-button @click="openUrl(downloadUrl)" type="primary" size="small">
|
||||
<el-icon style="margin-right: 4px;"><Download /></el-icon> 下载
|
||||
</el-button>
|
||||
<el-button @click="openUrl(getPreviewLink())" type="default" size="small">
|
||||
<el-icon style="margin-right: 4px;"><View /></el-icon> 预览
|
||||
</el-button>
|
||||
<el-tooltip :disabled="aria2Connected"
|
||||
@@ -155,14 +150,10 @@
|
||||
</template>
|
||||
<el-input :value="downloadUrl" readonly>
|
||||
<template #append>
|
||||
<el-tooltip :disabled="!needsDownloader"
|
||||
content="该网盘需使用下载器,无法直接复制直链" placement="top">
|
||||
<el-button v-clipboard:copy="downloadUrl" v-clipboard:success="onCopy"
|
||||
v-clipboard:error="onError" style="padding: 0 14px;"
|
||||
:disabled="needsDownloader">
|
||||
<el-icon><CopyDocument/></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-button v-clipboard:copy="downloadUrl" v-clipboard:success="onCopy"
|
||||
v-clipboard:error="onError" style="padding: 0 14px;">
|
||||
<el-icon><CopyDocument/></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<!-- 文件元信息 -->
|
||||
@@ -335,10 +326,6 @@
|
||||
<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>
|
||||
@@ -426,7 +413,6 @@
|
||||
:file-list="directoryData"
|
||||
:share-url="link"
|
||||
:password="password"
|
||||
:auth="directoryAuth"
|
||||
:view-mode="directoryViewMode"
|
||||
@file-click="handleFileClick"
|
||||
/>
|
||||
@@ -659,7 +645,6 @@ export default {
|
||||
// 目录树
|
||||
showDirectoryTree: false,
|
||||
directoryData: [],
|
||||
directoryAuth: '', // 目录解析时的加密 auth,透传给子目录/下载
|
||||
|
||||
// 统计信息
|
||||
node1Info: {},
|
||||
@@ -771,16 +756,6 @@ export default {
|
||||
thunder: '迅雷'
|
||||
}
|
||||
return map[this.aria2ConfigForm.downloaderType] || 'Aria2'
|
||||
},
|
||||
// 需要下载器(带 cookie 等特殊头)时禁用浏览器下载/复制直链;UC/夸克始终需要
|
||||
needsDownloader() {
|
||||
const pan = (this.getCurrentPanType() || '').toLowerCase()
|
||||
if (pan === 'uc' || pan === 'qk') return true
|
||||
const data = this.parseResult?.data
|
||||
if (!data) return false
|
||||
if (data.needDownloader || data.otherParam?.needDownloader) return true
|
||||
const headers = data.downloadHeaders || data.otherParam?.downloadHeaders
|
||||
return !!(headers && (headers.cookie || headers.Cookie))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -792,8 +767,7 @@ 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'
|
||||
// 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'
|
||||
if (url.includes('123pan.com') || url.includes('123684.com') || url.includes('123865.com')) return 'YE'
|
||||
return ''
|
||||
},
|
||||
|
||||
@@ -815,8 +789,7 @@ export default {
|
||||
'QK': '夸克网盘必须配置 Cookie 才能解析和下载(登录后从浏览器开发者工具获取)',
|
||||
'UC': 'UC网盘必须配置 Cookie 才能解析和下载(登录后从浏览器开发者工具获取)',
|
||||
'FJ': '小飞机网盘大文件(>100MB)需要配置认证信息',
|
||||
'IZ': '蓝奏优享大文件需要配置认证信息',
|
||||
'YE': '123云盘部分分享(需要登录才能查看/下载)需要配置账号密码或 Authorization Token'
|
||||
'IZ': '蓝奏优享大文件需要配置认证信息'
|
||||
}
|
||||
return hints[this.authConfig.panType] || '请选择网盘类型后配置认证信息'
|
||||
},
|
||||
@@ -1189,7 +1162,6 @@ export default {
|
||||
this.statisticsData = {}
|
||||
this.showDirectoryTree = false
|
||||
this.directoryData = []
|
||||
this.directoryAuth = ''
|
||||
},
|
||||
|
||||
// 统一API调用(自动添加认证参数)
|
||||
@@ -1198,12 +1170,10 @@ export default {
|
||||
this.errorBadgeVisible = false
|
||||
try {
|
||||
this.isLoading = true
|
||||
// 添加认证参数(已有则不覆盖,便于目录树透传同一份 auth)
|
||||
if (!params.auth) {
|
||||
const authParam = await this.generateAuthParam()
|
||||
if (authParam) {
|
||||
params.auth = authParam
|
||||
}
|
||||
// 添加认证参数(异步获取)
|
||||
const authParam = await this.generateAuthParam()
|
||||
if (authParam) {
|
||||
params.auth = authParam
|
||||
}
|
||||
const response = await axios.get(`${this.baseAPI}${endpoint}`, { params })
|
||||
|
||||
@@ -1260,11 +1230,10 @@ export default {
|
||||
duration: 5000,
|
||||
showClose: true
|
||||
})
|
||||
} else if (panType === 'fj' || panType === 'lz' || panType === 'iz' || panType === 'le' || panType === 'ye') {
|
||||
// 小飞机、蓝奏、优享、联想乐云、123云盘:提示大文件/需登录分享需要认证
|
||||
} else if (panType === 'fj' || panType === 'lz' || panType === 'iz' || panType === 'le') {
|
||||
// 小飞机、蓝奏、优享、联想乐云:提示大文件需要认证
|
||||
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({
|
||||
@@ -1291,13 +1260,11 @@ export default {
|
||||
// 更新智能直链(包含认证参数)
|
||||
this.updateDirectLink()
|
||||
// 如果需要下载器(含特殊头),弹出下载器对话框
|
||||
const needDownloader = !!(result.data?.needDownloader || otherParam.needDownloader
|
||||
|| otherParam.downloadHeaders?.cookie || otherParam.downloadHeaders?.Cookie)
|
||||
if (needDownloader) {
|
||||
if (result.data?.needDownloader) {
|
||||
this.downloadDialogInfo = {
|
||||
downloadUrl: result.data.directLink,
|
||||
fileName: result.data.fileInfo?.fileName || result.data.fileName || '',
|
||||
downloadHeaders: result.data.downloadHeaders || otherParam.downloadHeaders || {},
|
||||
fileName: result.data.fileName || '',
|
||||
downloadHeaders: result.data.downloadHeaders || {},
|
||||
aria2Command: this.aria2Command,
|
||||
curlCommand: this.curlCommand,
|
||||
aria2JsonRpc: this.aria2JsonRpc,
|
||||
@@ -1317,11 +1284,6 @@ export default {
|
||||
this.validateInput()
|
||||
const params = { url: this.link }
|
||||
if (this.password) params.pwd = this.password
|
||||
// 预先生成 auth,既给本次请求用,也透传给 DirectoryTree 子目录/下载
|
||||
this.directoryAuth = await this.generateAuthParam()
|
||||
if (this.directoryAuth) {
|
||||
params.auth = this.directoryAuth
|
||||
}
|
||||
|
||||
// 直接调用 getFileList,让后端返回错误(不做客户端类型检查)
|
||||
const directoryResult = await this.callAPI('/v2/getFileList', params)
|
||||
@@ -1650,14 +1612,12 @@ export default {
|
||||
|
||||
this.donateSubmitting = true
|
||||
try {
|
||||
// 只提交当前认证方式实际用到的字段,避免切换认证类型后遗留的用户名/密码脏数据被一起提交
|
||||
const isPasswordAuth = this.donateConfig.authType === 'password'
|
||||
const payload = {
|
||||
panType: this.donateConfig.panType,
|
||||
authType: this.donateConfig.authType,
|
||||
username: isPasswordAuth ? (this.donateConfig.username || '') : '',
|
||||
password: isPasswordAuth ? (this.donateConfig.password || '') : '',
|
||||
token: isPasswordAuth ? '' : (this.donateConfig.token || ''),
|
||||
username: this.donateConfig.username || '',
|
||||
password: this.donateConfig.password || '',
|
||||
token: this.donateConfig.token || '',
|
||||
remark: this.donateConfig.remark || ''
|
||||
}
|
||||
await axios.post(`${this.baseAPI}/v2/donateAccount`, payload)
|
||||
|
||||
@@ -386,16 +386,6 @@
|
||||
"type": "string",
|
||||
"example": "uuid123"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stoken",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "分享 token,用于子目录解析时复用认证状态",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"example": "OASBe5qM0pg2VvvyLM..."
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
@@ -30,16 +30,6 @@
|
||||
<groupId>cn.qaiu</groupId>
|
||||
<artifactId>parser</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
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";
|
||||
/** 原始加密 auth 查询串,供目录子链透传(避免进入子目录丢失认证) */
|
||||
public static final String AUTH_QUERY = "_authQuery";
|
||||
|
||||
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)) {
|
||||
// 保留原始 auth,供 getFileList 子目录 parserUrl 透传
|
||||
otherParam.put(AUTH_QUERY, 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,10 +68,9 @@ public class URLParamUtil {
|
||||
boolean firstParam = !decodedUrl.contains("?");
|
||||
|
||||
for (String paramName : params.names()) {
|
||||
// 忽略单独处理的参数,不应拼接到分享URL中
|
||||
if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId")
|
||||
&& !paramName.equals("uuid") && !paramName.equals("auth")
|
||||
&& !paramName.equals("stoken") && !paramName.equals("zml")) {
|
||||
// 忽略 "url", "pwd", "dirId", "uuid", "auth" 参数(这些参数单独处理,不应拼接到分享URL中)
|
||||
if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId")
|
||||
&& !paramName.equals("uuid") && !paramName.equals("auth")) {
|
||||
if (firstParam) {
|
||||
urlBuilder.append("?");
|
||||
firstParam = false;
|
||||
@@ -101,27 +100,21 @@ 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)) {
|
||||
// 需要处理引号
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ 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;
|
||||
@@ -16,9 +15,7 @@ import cn.qaiu.lz.web.service.DbService;
|
||||
import cn.qaiu.parser.PanDomainTemplate;
|
||||
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.util.CommonUtils;
|
||||
import cn.qaiu.vx.core.annotaions.RouteHandler;
|
||||
import cn.qaiu.vx.core.annotaions.RouteMapping;
|
||||
import cn.qaiu.vx.core.enums.RouteMethod;
|
||||
@@ -162,8 +159,7 @@ public class ParserApi {
|
||||
}
|
||||
|
||||
@RouteMapping("/getFileList")
|
||||
public Future<List<FileInfo>> getFileList(HttpServerRequest request, String pwd, String dirId, String uuid,
|
||||
String stoken, String zml, String auth) {
|
||||
public Future<List<FileInfo>> getFileList(HttpServerRequest request, String pwd, String dirId, String uuid) {
|
||||
String url = URLParamUtil.parserParams(request);
|
||||
ParserCreate parserCreate;
|
||||
try {
|
||||
@@ -172,37 +168,21 @@ 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);
|
||||
}
|
||||
if (StringUtils.isNotBlank(zml)) {
|
||||
parserCreate.getShareLinkInfo().getOtherParam().put("zml", zml);
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
IPanTool tool = parserCreate.createTool();
|
||||
return IPanTool.closeAfter(tool, tool::parseFileList);
|
||||
}
|
||||
|
||||
// 目录解析下载文件
|
||||
// @RouteMapping("/getFileDownUrl/:type/:param")
|
||||
public Future<String> getFileDownUrl(HttpServerRequest request, String type, String param, String auth) {
|
||||
public Future<String> getFileDownUrl(HttpServerRequest request, String type, String param) {
|
||||
ParserCreate parserCreate = ParserCreate.fromType(type).shareKey("-") // shareKey not null
|
||||
.setShareLinkInfoPwd("-");
|
||||
|
||||
@@ -212,41 +192,23 @@ public class ParserApi {
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
final String paramStr;
|
||||
try {
|
||||
paramStr = CommonUtils.urlBase64Decode(param);
|
||||
} catch (Exception e) {
|
||||
Promise<String> promise = Promise.promise();
|
||||
promise.fail("下载参数解码失败: " + e.getMessage());
|
||||
return promise.future();
|
||||
}
|
||||
String paramStr = new String(Base64.getDecoder().decode(param));
|
||||
ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo();
|
||||
shareLinkInfo.getOtherParam().put("paramJson", new JsonObject(paramStr));
|
||||
|
||||
// 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)
|
||||
.onFailure(t -> {
|
||||
ParserAuthUtil.recordDonatedAccountFailureIfNeeded(dbService, otherParam, t);
|
||||
ParserAuthUtil.recordAutoDonatedFailureIfNeeded(dbService,
|
||||
parserCreate.getShareLinkInfo(), t);
|
||||
});
|
||||
});
|
||||
IPanTool tool = parserCreate.createTool();
|
||||
return IPanTool.closeAfter(tool, tool::parseById);
|
||||
}
|
||||
|
||||
@RouteMapping("/redirectUrl/:type/:param")
|
||||
public Future<Void> redirectUrl(HttpServerRequest request, HttpServerResponse response, String type, String param,
|
||||
String auth) {
|
||||
public Future<Void> redirectUrl(HttpServerRequest request, HttpServerResponse response, String type, String param) {
|
||||
Promise<Void> promise = Promise.promise();
|
||||
|
||||
getFileDownUrl(request, type, param, auth)
|
||||
getFileDownUrl(request, type, param)
|
||||
.onSuccess(res -> {
|
||||
ResponseUtil.redirect(response, res, promise);
|
||||
})
|
||||
@@ -254,131 +216,6 @@ public class ParserApi {
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录文件下载信息(供前端下载器使用):返回直链、请求头及命令行
|
||||
*/
|
||||
@RouteMapping("/getFileDownInfo/:type/:param")
|
||||
public Future<JsonObject> getFileDownInfo(HttpServerRequest request, String type, String param, String auth) {
|
||||
ParserCreate parserCreate;
|
||||
try {
|
||||
parserCreate = ParserCreate.fromType(type).shareKey("-").setShareLinkInfoPwd("-");
|
||||
} catch (Exception e) {
|
||||
return Future.failedFuture(e);
|
||||
}
|
||||
|
||||
if (param == null || param.isEmpty()) {
|
||||
return Future.failedFuture("下载参数为空");
|
||||
}
|
||||
|
||||
final JsonObject paramJson;
|
||||
try {
|
||||
paramJson = new JsonObject(CommonUtils.urlBase64Decode(param));
|
||||
} catch (Exception e) {
|
||||
return Future.failedFuture("下载参数解码失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo();
|
||||
shareLinkInfo.getOtherParam().put("paramJson", paramJson);
|
||||
|
||||
String linkPrefix = getLinkPrefix(request);
|
||||
JsonObject otherParam = ParserAuthUtil.buildOtherParam(request, auth, linkPrefix);
|
||||
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)
|
||||
.onFailure(t -> {
|
||||
ParserAuthUtil.recordDonatedAccountFailureIfNeeded(dbService, otherParam, t);
|
||||
ParserAuthUtil.recordAutoDonatedFailureIfNeeded(dbService,
|
||||
parserCreate.getShareLinkInfo(), t);
|
||||
})
|
||||
.map(downloadUrl -> buildFileDownInfo(shareLinkInfo, paramJson, downloadUrl));
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static JsonObject buildFileDownInfo(ShareLinkInfo shareLinkInfo, JsonObject paramJson, String downloadUrl) {
|
||||
Map<String, String> downloadHeaders = new HashMap<>();
|
||||
// 入口参数里可能已带 cookie(目录解析时写入),先作为底稿
|
||||
mergeDownloadHeaders(downloadHeaders, paramJson.getJsonObject("downloadHeaders"));
|
||||
// 解析器运行时生成的请求头优先覆盖(如刷新后的 cookie),但跳过 null
|
||||
Object headersObj = shareLinkInfo.getOtherParam().get("downloadHeaders");
|
||||
if (headersObj instanceof Map) {
|
||||
mergeDownloadHeaders(downloadHeaders, (Map<?, ?>) headersObj);
|
||||
}
|
||||
|
||||
String fileName = paramJson.getString("fileName", "");
|
||||
if (StringUtils.isBlank(fileName)) {
|
||||
Object fn = shareLinkInfo.getOtherParam().get("fileName");
|
||||
if (fn != null) {
|
||||
fileName = fn.toString();
|
||||
}
|
||||
}
|
||||
|
||||
boolean needDownloader = Boolean.TRUE.equals(paramJson.getBoolean("needDownloader"))
|
||||
|| !downloadHeaders.isEmpty();
|
||||
|
||||
shareLinkInfo.getOtherParam().put("downloadUrl", downloadUrl);
|
||||
if (!downloadHeaders.isEmpty()) {
|
||||
shareLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders);
|
||||
}
|
||||
|
||||
JsonObject result = new JsonObject()
|
||||
.put("downloadUrl", downloadUrl)
|
||||
.put("fileName", fileName)
|
||||
.put("needDownloader", needDownloader)
|
||||
.put("downloadHeaders", downloadHeaders);
|
||||
|
||||
try {
|
||||
Map<ClientLinkType, String> clientLinks = ClientLinkGeneratorFactory.generateAll(shareLinkInfo);
|
||||
if (clientLinks.containsKey(ClientLinkType.CURL)) {
|
||||
result.put("curlCommand", clientLinks.get(ClientLinkType.CURL));
|
||||
}
|
||||
if (clientLinks.containsKey(ClientLinkType.ARIA2)) {
|
||||
result.put("aria2Command", clientLinks.get(ClientLinkType.ARIA2));
|
||||
}
|
||||
if (clientLinks.containsKey(ClientLinkType.THUNDER)) {
|
||||
result.put("thunderLink", clientLinks.get(ClientLinkType.THUNDER));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("生成下载命令失败: {}", e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并下载请求头,忽略 null/空值,避免运行时 null 覆盖入口参数中的 cookie。
|
||||
*/
|
||||
private static void mergeDownloadHeaders(Map<String, String> target, JsonObject source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (String key : source.fieldNames()) {
|
||||
Object val = source.getValue(key);
|
||||
if (val != null && StringUtils.isNotBlank(val.toString())) {
|
||||
target.put(key, val.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void mergeDownloadHeaders(Map<String, String> target, Map<?, ?> source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Map.Entry<?, ?> e : source.entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
String val = e.getValue().toString();
|
||||
if (StringUtils.isNotBlank(val)) {
|
||||
target.put(e.getKey().toString(), val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 预览媒体文件
|
||||
@@ -446,12 +283,11 @@ public class ParserApi {
|
||||
|
||||
|
||||
@RouteMapping("/viewUrl/:type/:param")
|
||||
public Future<Void> viewUrl(HttpServerRequest request, HttpServerResponse response, String type, String param,
|
||||
String auth) {
|
||||
public Future<Void> viewUrl(HttpServerRequest request, HttpServerResponse response, String type, String param) {
|
||||
Promise<Void> promise = Promise.promise();
|
||||
|
||||
String viewPrefix = SharedDataUtil.getJsonConfig("server").getString("previewURL");
|
||||
getFileDownUrl(request, type, param, auth)
|
||||
getFileDownUrl(request, type, param)
|
||||
.onSuccess(res -> {
|
||||
String url = viewPrefix + URLEncoder.encode(res, StandardCharsets.UTF_8);
|
||||
ResponseUtil.redirect(response, url, promise);
|
||||
@@ -463,14 +299,11 @@ public class ParserApi {
|
||||
// 获取版本号
|
||||
@RouteMapping("/build-version")
|
||||
public String getVersion() {
|
||||
String version = CommonUtil.getAppVersion();
|
||||
if (version == null || version.isBlank()) {
|
||||
return "unknown";
|
||||
}
|
||||
return version
|
||||
return CommonUtil.getAppVersion()
|
||||
.replace("-", "")
|
||||
.replace("Z", "")
|
||||
.replace("T", "_")
|
||||
.replace("-", "")
|
||||
.replace(":", "");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package cn.qaiu.lz.web.controller;
|
||||
|
||||
import cn.qaiu.lz.common.util.ParserAuthUtil;
|
||||
import cn.qaiu.lz.common.util.AuthParamCodec;
|
||||
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;
|
||||
@@ -28,7 +29,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@RouteHandler("/")
|
||||
public class ServerApi {
|
||||
|
||||
private static final String SKIP_CLIENT_LINKS = ParserAuthUtil.SKIP_CLIENT_LINKS;
|
||||
private static final String SKIP_CLIENT_LINKS = "_skipClientLinks";
|
||||
|
||||
private final CacheService cacheService = AsyncServiceUtil.getAsyncServiceInstance(CacheService.class);
|
||||
private final DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class);
|
||||
@@ -46,7 +47,7 @@ public class ServerApi {
|
||||
addCacheHeaders(response, res),
|
||||
res.getDirectLink(), promise))
|
||||
.onFailure(t -> {
|
||||
ParserAuthUtil.recordDonatedAccountFailureIfNeeded(dbService, otherParam, t);
|
||||
recordDonatedAccountFailureIfNeeded(otherParam, t);
|
||||
promise.tryFail(t);
|
||||
});
|
||||
return promise.future();
|
||||
@@ -57,14 +58,14 @@ public class ServerApi {
|
||||
String url = URLParamUtil.parserParams(request);
|
||||
JsonObject otherParam = buildOtherParam(request, auth);
|
||||
return cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam)
|
||||
.onFailure(t -> ParserAuthUtil.recordDonatedAccountFailureIfNeeded(dbService, otherParam, t));
|
||||
.onFailure(t -> recordDonatedAccountFailureIfNeeded(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 -> ParserAuthUtil.recordDonatedAccountFailureIfNeeded(dbService, otherParam, t));
|
||||
.onFailure(t -> recordDonatedAccountFailureIfNeeded(otherParam, t));
|
||||
}
|
||||
|
||||
@RouteMapping(value = "/json/:type/:key", method = RouteMethod.GET)
|
||||
@@ -145,6 +146,66 @@ public class ServerApi {
|
||||
}
|
||||
|
||||
private JsonObject buildOtherParam(HttpServerRequest request, String auth, boolean skipClientLinks) {
|
||||
return ParserAuthUtil.buildOtherParam(request, auth, resolveOrigin(request), 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("认证");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,14 @@ 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;
|
||||
@@ -30,10 +27,9 @@ import java.util.Map;
|
||||
@Slf4j
|
||||
public class CacheServiceImpl implements CacheService {
|
||||
|
||||
private static final String SKIP_CLIENT_LINKS = ParserAuthUtil.SKIP_CLIENT_LINKS;
|
||||
private static final String SKIP_CLIENT_LINKS = "_skipClientLinks";
|
||||
|
||||
private final CacheManager cacheManager = new CacheManager();
|
||||
private final DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class);
|
||||
|
||||
static {
|
||||
// 服务类加载时注册缓存定时清理任务
|
||||
@@ -75,9 +71,7 @@ public class CacheServiceImpl implements CacheService {
|
||||
promise.fail(cause);
|
||||
return;
|
||||
}
|
||||
IPanTool.closeAfter(tool, tool::parse).onFailure(err -> {
|
||||
ParserAuthUtil.recordAutoDonatedFailureIfNeeded(dbService, shareLinkInfo, err);
|
||||
}).onSuccess(redirectUrl -> {
|
||||
IPanTool.closeAfter(tool, tool::parse).onSuccess(redirectUrl -> {
|
||||
// 使用 effectiveCacheDuration
|
||||
long expires = System.currentTimeMillis() + effectiveCacheDuration * 60 * 1000L;
|
||||
result.setDirectLink(redirectUrl);
|
||||
@@ -186,11 +180,6 @@ public class CacheServiceImpl implements CacheService {
|
||||
// 传递 downloadHeaders 到两个对象
|
||||
cacheLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders);
|
||||
result.getOtherParam().put("downloadHeaders", downloadHeaders);
|
||||
// 有特殊下载头时标记需要下载器(浏览器无法带 cookie 直连)
|
||||
if (!downloadHeaders.isEmpty()) {
|
||||
cacheLinkInfo.getOtherParam().put("needDownloader", true);
|
||||
result.getOtherParam().put("needDownloader", true);
|
||||
}
|
||||
|
||||
// 使用已有的工具类生成下载命令
|
||||
generateCommandsFromShareLinkInfo(shareLinkInfo, cacheLinkInfo, result);
|
||||
@@ -289,9 +278,8 @@ public class CacheServiceImpl implements CacheService {
|
||||
} catch (Exception e) {
|
||||
return Future.failedFuture(e);
|
||||
}
|
||||
ParserCreate finalParserCreate = parserCreate;
|
||||
return ParserAuthUtil.applyAuthParamsAndDonatedFallback(finalParserCreate, otherParam, dbService)
|
||||
.compose(v -> getAndSaveCachedShareLink(finalParserCreate));
|
||||
parserCreate.getShareLinkInfo().getOtherParam().putAll(otherParam.getMap());
|
||||
return getAndSaveCachedShareLink(parserCreate);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -302,8 +290,22 @@ public class CacheServiceImpl implements CacheService {
|
||||
} catch (Exception e) {
|
||||
return Future.failedFuture(e);
|
||||
}
|
||||
ParserCreate finalParserCreate = parserCreate;
|
||||
return ParserAuthUtil.applyAuthParamsAndDonatedFallback(finalParserCreate, otherParam, dbService)
|
||||
.compose(v -> getAndSaveCachedShareLink(finalParserCreate));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,16 +325,9 @@ public class DbServiceImpl implements DbService {
|
||||
public Future<JsonObject> saveDonatedAccount(JsonObject account) {
|
||||
JDBCPool client = JDBCPoolInit.instance().getPool();
|
||||
|
||||
// 只保留当前认证方式实际用到的字段,避免调用方切换认证类型后遗留的用户名/密码脏数据
|
||||
// 被一并存入库中,导致后续解析时被误当作真实凭证使用(例如把废弃的用户名当手机号登录)。
|
||||
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);
|
||||
Future<String> encryptedUsername = CryptoUtil.encrypt(account.getString("username"));
|
||||
Future<String> encryptedPassword = CryptoUtil.encrypt(account.getString("password"));
|
||||
Future<String> encryptedToken = CryptoUtil.encrypt(account.getString("token"));
|
||||
|
||||
return ensureFailCountColumn(client).compose(v ->
|
||||
Future.all(encryptedUsername, encryptedPassword, encryptedToken).compose(compositeFuture -> {
|
||||
@@ -419,14 +412,6 @@ 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