Compare commits

...

19 Commits

Author SHA1 Message Date
qaiu c18b8d80e1 更新 README.md 2026-08-07 09:36:35 +08:00
qaiu 06ba0aff52 Merge pull request #211 from qaiu/copilot/update-readme-to-latest-version
docs: update download URL to latest release v0.4.2
2026-08-05 21:42:39 +08:00
copilot-swe-agent[bot] 74e401d99c docs: fix download URL and zip filename for v0.4.2 linux-amd64
Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-08-05 13:28:37 +00:00
copilot-swe-agent[bot] 4995bca901 docs: update download URL to latest release v0.4.2
Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-08-05 13:18:11 +00:00
qaiu 7299fd8762 Merge pull request #207 from qaiu/cursor/ghsa-997r-ssrf-verify-2b8b
fix(security): GHSA-997r SSRF verification + residual hardening
2026-07-26 21:10:39 +08:00
Cursor Agent efbadde4ed fix(security): harden GHSA-997r Cloudreve SSRF residual paths
Disable redirect following on CE/Ce4 attacker-controlled requests and stop
echoing upstream response bodies in client-facing JSON errors. Add
assertPublicHost regression coverage for the advisory PoC hosts.

Co-authored-by: qaiu <qaiu@vip.qq.com>
2026-07-26 12:05:16 +00:00
q d1fa787bef fix(uc/qk): improve directory download with cookie, transfer fallback and URL-safe param encoding
- Preserve download cookie for UC/Quark needDownloader flows and disable browser/copy when required
- Quark: share-link first, transfer only on size limit (23018), reuse savedFileCache/search_exit
- Propagate auth to subdirectory parser URLs; switch path params to URL-safe Base64 (no double encode)
- Bump version to 0.4.2

Fixes #205

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:16:51 +08:00
q c282fcb109 Merge branch 'main' into feature/ysepan-parser 2026-07-26 13:20:16 +08:00
qaiu 1c1068beba Merge pull request #206 from qaiu/dependabot/maven/maven-858658338e
chore(deps): bump the maven group across 1 directory with 2 updates
2026-07-26 09:04:56 +08:00
qaiu 4a5240fcbc Merge pull request #203 from qaiu/dependabot/npm_and_yarn/web-front/npm_and_yarn-c42958dede
build(deps): bump axios from 1.16.1 to 1.18.0 in /web-front in the npm_and_yarn group across 1 directory
2026-07-26 08:47:25 +08:00
dependabot[bot] 0f34672e33 chore(deps): bump the maven group across 1 directory with 2 updates
Bumps the maven group with 2 updates in the / directory: [ch.qos.logback:logback-core](https://github.com/qos-ch/logback) and [com.fasterxml.jackson.core:jackson-databind](https://github.com/FasterXML/jackson).


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

Updates `com.fasterxml.jackson.core:jackson-databind` from 2.18.6 to 2.18.9
- [Commits](https://github.com/FasterXML/jackson/commits)

---
updated-dependencies:
- dependency-name: ch.qos.logback:logback-core
  dependency-version: 1.5.34
  dependency-type: direct:production
  dependency-group: maven
- dependency-name: com.fasterxml.jackson.core:jackson-databind
  dependency-version: 2.18.9
  dependency-type: direct:production
  dependency-group: maven
...

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

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

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

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

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


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 07:29:38 +00:00
dependabot[bot] 363c603bbb chore(deps): bump ch.qos.logback:logback-core
Bumps the maven group with 1 update in the / directory: [ch.qos.logback:logback-core](https://github.com/qos-ch/logback).


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-06 02:09:01 +00:00
25 changed files with 1934 additions and 321 deletions
+24 -7
View File
@@ -455,8 +455,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/v3.0.2/netdisk-fast-download-bin.zip
unzip netdisk-fast-download-bin.zip
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v0.4.2/netdisk-fast-download-linux-amd64.zip
unzip netdisk-fast-download.zip
cd netdisk-fast-download
bash service-install.sh
```
@@ -536,13 +536,30 @@ Core模块集成Vert.x实现类似spring的注解式路由API
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=qaiu/netdisk-fast-download&type=Date)](https://star-history.com/#qaiu/netdisk-fast-download&Date)
<a href="https://www.star-history.com/?repos=qaiu%2Fnetdisk-fast-download&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&theme=dark&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
</picture>
</a>
## **免责声明**
- 用户在使用本项目时,应自行承担风险,并确保其行为符合当地法律法规。开发者不对用户因使用本项目而导致的任何后果负责。
## 支持该项目
开源不易,用爱发电,本项目长期维护如果觉得有帮助, 可以请作者喝杯咖啡, 感谢支持
## **声明**
- 本项目**仅供个人学习与技术交流使用**,请勿用于商业用途或大规模滥用。
- 所用接口均来自各网盘**官方开放平台**、官方公开接口及**已知开源项目**,仅解析用户主动提供的分享链接,**不涉及破解或绕过版权保护**。
- 本项目**相当于自动化程序代替用户发起请求**,**不会提升或绕过任何会员权限**,也**不会突破网盘的限速策略**;我们**鼓励用户开通官方 VIP/SVIP**以获得更好的下载与加速体验。
- 使用者应遵守各网盘服务商的**用户协议与使用条款**,以及所在地区的**法律法规**,违反所产生的后果自行承担。
- **高频调用可能触发网盘风控**,导致**账号限流、封禁**或**来源 IP 被屏蔽**,请合理控制请求频率,避免高并发与批量请求。
- 网盘接口调整、风控策略变更等因素可能导致功能失效,本项目**不对可用性与稳定性作任何担保**。
- 开发者**不对**使用本项目产生的**任何直接或间接损失**(账号封禁、数据丢失、法律纠纷等)**承担责任**。
- 开发者保留**随时修改本免责声明**的权利,恕不另行通知。
- **下载、部署或使用本项目,即视为您已阅读并接受上述全部条款。**
## 赞助该项目
开源不易,用爱发电,本项目长期维护如果觉得有帮助, 可以请开发者[喝杯咖啡](https://blog.qaiu.top/archives/da-shang-zhuan-yong), 感谢支持。
本项目的服务器由林枫云提供赞助<br>
</a>
+1 -1
View File
@@ -65,7 +65,7 @@
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.11</version>
<version>42.7.13</version>
</dependency>
</dependencies>
@@ -382,9 +382,10 @@ 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格式异常: {}", bodyPreview);
fail("解析失败: json格式异常");
}
return JsonObject.of();
}
@@ -414,7 +415,18 @@ 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()) {
shareLinkInfo.getOtherParam().put("downloadHeaders", headers);
// 过滤 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);
}
}
promise.complete(url);
}
@@ -522,6 +534,34 @@ 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;
@@ -378,8 +378,11 @@ public enum PanDomainTemplate {
// =====================私有盘解析==========================
// 永硕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|ys168)\\.com/?(?:\\?.*)?"),
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),
@@ -1,53 +1,149 @@
package cn.qaiu.parser;
import org.apache.commons.lang3.StringUtils;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Parser token cache keyed by parser type and account identity.
* 解析器 Token/Cookie 缓存 — 支持多账号隔离。
* <p>
* 以 (diskType + "#" + accountKey) 作为缓存 key,不同账号的 token 互不覆盖。
* accountKey 优先使用 _configId,其次使用 username、cookie 前16位等可区分标识。
* </p>
*/
public final class TokenCache {
private static final Map<String, String> TOKENS = new ConcurrentHashMap<>();
private static final Map<String, Long> EXPIRES = new ConcurrentHashMap<>();
private TokenCache() {}
private TokenCache() {
/** 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);
}
public static String key(String type, String accountId) {
return type + ":" + (StringUtils.isBlank(accountId) ? "_default" : accountId);
// ============ token ============
public static String getToken(String cacheKey) {
return tokenMap.get(cacheKey);
}
public static void putToken(String key, String token) {
if (StringUtils.isBlank(key) || StringUtils.isBlank(token)) {
return;
public static void putToken(String cacheKey, String token) {
if (token == null) {
tokenMap.remove(cacheKey);
} else {
tokenMap.put(cacheKey, token);
}
TOKENS.put(key, token);
}
public static String getToken(String key) {
if (StringUtils.isBlank(key)) {
return null;
}
if (isExpired(key)) {
TOKENS.remove(key);
EXPIRES.remove(key);
return null;
}
return TOKENS.get(key);
// ============ expire ============
public static long getExpire(String cacheKey) {
return expireMap.getOrDefault(cacheKey, 0L);
}
public static void putExpire(String key, long expireTimeMillis) {
if (StringUtils.isBlank(key)) {
return;
}
EXPIRES.put(key, expireTimeMillis);
public static void putExpire(String cacheKey, long expireMs) {
expireMap.put(cacheKey, expireMs);
}
public static boolean isExpired(String key) {
Long expireTimeMillis = EXPIRES.get(key);
return expireTimeMillis != null && System.currentTimeMillis() > expireTimeMillis;
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);
}
}
// ============ 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)});
}
}
/**
* 凭据本身被替换(如 PaliTool refresh_token 轮换),加入回写队列
*/
public static void queueCredentialUpdate(String cacheKey, String newCredential) {
if (cacheKey != null && newCredential != null) {
credentialUpdateQueue.put(cacheKey, newCredential);
}
}
/**
* 消费持久化队列:返回 [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);
}
}
@@ -111,7 +111,8 @@ public class Ce4Tool extends PanBase {
private void requestShareDetail(String baseUrl, String key, String pwd, String path) {
String shareApiUrl = baseUrl + SHARE_API_PATH + key;
HttpRequest<Buffer> httpRequest = clientSession.getAbs(shareApiUrl);
// 禁止跟随重定向:防止公网 host 302 到内网/元数据绕过 assertPublicHost
HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
if (pwd != null && !pwd.isEmpty()) {
httpRequest.addQueryParam("password", pwd);
}
@@ -232,7 +233,7 @@ public class Ce4Tool extends PanBase {
.put("uris", new JsonArray().add(filePath))
.put("download", true);
clientSession.postAbs(fileUrlApi)
clientNoRedirects.postAbs(fileUrlApi)
.putHeader("Content-Type", "application/json")
.sendJsonObject(requestBody)
.onSuccess(res -> {
@@ -78,7 +78,8 @@ public class CeTool extends PanBase {
private void tryV4Ping(String baseUrl, String key, String pwd) {
String pingUrlV4 = baseUrl + PING_API_V4_PATH;
clientSession.getAbs(pingUrlV4).send().onSuccess(res -> {
// 禁止跟随重定向:assertPublicHost 只校验初始 host,自动 30x 会绕过 SSRF 防护
clientNoRedirects.getAbs(pingUrlV4).send().onSuccess(res -> {
if (res.statusCode() == 200) {
try {
JsonObject json = asJson(res);
@@ -108,7 +109,7 @@ public class CeTool extends PanBase {
private void tryV3Ping(String baseUrl, String key, String pwd) {
String pingUrlV3 = baseUrl + PING_API_V3_PATH;
clientSession.getAbs(pingUrlV3).send().onSuccess(res -> {
clientNoRedirects.getAbs(pingUrlV3).send().onSuccess(res -> {
if (res.statusCode() == 200) {
try {
JsonObject json = asJson(res);
@@ -139,7 +140,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 = clientSession.getAbs(shareApiUrl);
HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
if (pwd != null && !pwd.isEmpty()) {
httpRequest.addQueryParam("password", pwd);
}
@@ -175,7 +176,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 = clientSession.getAbs(shareApiUrl);
HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
if (pwd != null && !pwd.isEmpty()) {
httpRequest.addQueryParam("password", pwd);
}
@@ -291,7 +292,8 @@ public class CeTool extends PanBase {
}
private void getDownURL(String shareApiUrl) {
clientSession.putAbs(shareApiUrl)
// PUT 默认不跟随重定向,但仍统一使用 no-redirect 客户端避免配置漂移
clientNoRedirects.putAbs(shareApiUrl)
.putHeader("Referer", shareLinkInfo.getShareUrl())
.send().onSuccess(res -> {
JsonObject jsonObject = asJson(res);
File diff suppressed because it is too large Load Diff
@@ -265,13 +265,8 @@ public class UcTool extends PanBase {
return;
}
String downloadUrl = dataList.getJsonObject(0).getString("download_url");
// 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);
// UC 需配合下载器(带 cookie,保存下载请求头
completeWithMeta(downloadUrl, buildDownloadHeaders(null));
} catch (Exception e) {
fail("解析 UC 下载链接失败: " + e.getMessage());
}
@@ -466,29 +461,40 @@ 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());
fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(), shareLinkInfo.getType(), param));
// 透传 auth,避免下载/转存时变成 guest
fileInfo.setParserUrl(appendAuthQuery(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(), shareLinkInfo.getType(), param)));
} else {
// 文件夹
fileInfo.setFileType("folder");
fileInfo.setSize(0L);
fileInfo.setSizeStr("0B");
// 设置目录解析URL(用于递归解析子目录)
// 对 URL 参数进行编码,确保特殊字符正确传递
// 递归子目录须透传 auth,否则会丢失认证
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(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), encodedUrl, encodedDirId, encodedStoken));
fileInfo.setParserUrl(appendAuthQuery(String.format(
"%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), encodedUrl, encodedDirId, encodedStoken)));
} catch (Exception e) {
// 如果编码失败,使用原始值
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken));
fileInfo.setParserUrl(appendAuthQuery(String.format(
"%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken)));
}
}
@@ -510,6 +516,9 @@ 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");
@@ -554,6 +563,11 @@ 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());
@@ -564,6 +578,53 @@ 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(
@@ -24,7 +24,7 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 永硕E盘 (ysepan.com / ys168.com)
* 永硕E盘(主 ysepan.com / ys168.com,备 cccpan.com / ysupan.com / uupan.net / ysok.net
* <p>
* 空间分享形如 https://{space}.ysepan.com/ ,需空间访问密码时通过 sharePassword 传入。
*/
@@ -42,6 +42,8 @@ public class YsTool extends PanBase {
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);
@@ -88,9 +90,10 @@ public class YsTool extends PanBase {
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));
listPromise.complete(mapFiles(session, dirId, filesResp, zmlFilter));
} catch (Exception e) {
listPromise.fail(baseMsg() + " - 解析文件列表失败: " + e.getMessage());
}
@@ -132,7 +135,7 @@ public class YsTool extends PanBase {
fail("下载参数不完整: {}", paramJson);
return promise.future();
}
String url = buildDownloadUrl(space, xzpz, pz, fwq, fileName, true);
String url = buildDownloadUrl(space, xzpz, pz, fwq, fileName);
completeWithMeta(url, downloadHeaders(paramJson.getString("referer", spaceOrigin())));
return promise.future();
}
@@ -375,36 +378,94 @@ public class YsTool extends PanBase {
return result;
}
private List<FileInfo> mapFiles(Session session, int dirId, JsonObject filesResp) {
/**
* 将目录内文件按 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 wjlx = item.getString("wjlx", "");
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 title = StringUtils.defaultIfBlank(item.getString("bt"), item.getString("wjm", "链接"));
String link = item.getString("wjm", "");
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(item.getString("zml", ""))
.setFilePath(itemZml)
.setCreateTime(normalizeTime(item.getString("sj")))
.setPanType(shareLinkInfo.getType())
.setPreviewUrl(link)
.setDescription(link);
// parserUrl 置空,避免前端误走下载;打开走 previewUrl
result.add(urlInfo);
continue;
}
@@ -417,7 +478,7 @@ public class YsTool extends PanBase {
}
long size = item.getLong("dx", 0L);
String downloadUrl = buildDownloadUrl(session.space, xzpz, pz, fwq, fileName, true);
String downloadUrl = buildDownloadUrl(session.space, xzpz, pz, fwq, fileName);
JsonObject param = new JsonObject()
.put("space", session.space)
.put("xzpz", xzpz)
@@ -436,7 +497,7 @@ public class YsTool extends PanBase {
.setFileType("file")
.setSize(size)
.setSizeStr(FileSizeConverter.convertToReadableSize(size))
.setFilePath(item.getString("zml", ""))
.setFilePath(itemZml)
.setCreateTime(normalizeTime(item.getString("sj")))
.setPanType(shareLinkInfo.getType())
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
@@ -448,6 +509,18 @@ public class YsTool extends PanBase {
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", "");
@@ -474,7 +547,7 @@ public class YsTool extends PanBase {
private void completeDownload(Session session, JsonObject filesResp, JsonObject file) {
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz");
String url = buildDownloadUrl(session.space, xzpz, file.getString("pz"),
file.getString("fwq"), file.getString("wjm"), true);
file.getString("fwq"), file.getString("wjm"));
FileInfo fileInfo = new FileInfo()
.setFileName(file.getString("wjm"))
@@ -489,15 +562,16 @@ public class YsTool extends PanBase {
completeWithMeta(url, downloadHeaders(session.origin + "/"));
}
static String buildDownloadUrl(String space, String xzpz, String pz, String fwq,
String fileName, boolean forceDownload) {
String token = forceDownload ? "_" + xzpz : xzpz;
/**
* 拼装直链。注意:不要在 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(token) + "/"
+ encodePathSegment(xzpz) + "/"
+ encodePathSegment(pz) + "/"
+ encodePathSegment(fileName);
}
@@ -77,32 +77,57 @@ public class CommonUtils {
}
/**
* urlEncode -> deBase64 -> string
* @param encoded 编码后的字符串
* @return 解码后的字符串
* 解码路径参数中的 Base64。
* <p>优先按 URL-Safe Base64 解;兼容历史「标准 Base64 + URLEncode」以及重复 encode。</p>
*/
public static String urlBase64Decode(String encoded) {
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);
if (encoded == null || encoded.isEmpty()) {
throw new RuntimeException("URL Base64 解码失败: empty");
}
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);
}
/**
* string -> base64Encode -> urlEncode
* @param str 原始字符串
* @return 编码后的字符串
* 编码为可直接放进 URL path 的 Base64URL-Safe,无 padding)。
* <p>不再做 URLEncoder,避免前端/代理再 encode 时变成 %253D。</p>
*/
public static String urlBase64Encode(String str) {
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 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;
}
return s + "====".substring(mod);
}
}
@@ -0,0 +1,44 @@
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,6 +323,63 @@ 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() {
// 测试文件链接解析
@@ -6,7 +6,9 @@ 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;
@@ -19,20 +21,25 @@ import java.util.regex.Pattern;
import static org.junit.Assert.*;
/**
* 永硕E盘解析测试(含示例空间联调)
* 永硕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();
}
@@ -54,10 +61,16 @@ public class YsToolTest {
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
@@ -71,19 +84,40 @@ public class YsToolTest {
@Test
public void testBuildDownloadUrl() {
String url = YsTool.buildDownloadUrl(
"qaiu",
"UOkGHeA9O9hFJHG",
"rEBaljD.Ba69AMzTBmAb9AC9CPvC2E",
"C",
"Pycharm2023.1激活.zip",
true);
"yssl",
"A95UIe495EkSKE",
"Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC",
"L",
"lu20.jpg");
// 与官方一致:xzpz 前不加 "_"
assertEquals(
"https://ys-c.ysepan.com/wap/qaiu/_UOkGHeA9O9hFJHG/rEBaljD.Ba69AMzTBmAb9AC9CPvC2E/Pycharm2023.1%E6%BF%80%E6%B4%BB.zip",
"https://ys-l.ysepan.com/wap/yssl/A95UIe495EkSKE/Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC/lu20.jpg",
url);
assertFalse("force-download 前缀会导致 404", url.contains("/_"));
}
@Test
public void testQaiuSpaceFileListAndDownload() throws Exception {
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");
@@ -118,8 +152,9 @@ public class YsToolTest {
assertNotNull(zip.getParserUrl());
String param = zip.getParserUrl().substring(zip.getParserUrl().lastIndexOf('/') + 1);
String decoded = CommonUtils.urlBase64Decode(param);
JsonObject paramJson = new JsonObject(decoded);
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");
@@ -130,9 +165,74 @@ public class YsToolTest {
.get(60, TimeUnit.SECONDS);
assertNotNull(downloadUrl);
assertTrue(downloadUrl.contains("ys-c.ysepan.com") || downloadUrl.contains("ysepan.com"));
assertTrue(downloadUrl.contains("Pycharm") || downloadUrl.contains("%E6%BF%80%E6%B4%BB"));
System.out.println("qaiu downloadUrl=" + downloadUrl);
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
@@ -166,8 +266,47 @@ public class YsToolTest {
assertNotNull(files);
assertFalse(files.isEmpty());
assertTrue("应包含文件或URL条目",
files.stream().anyMatch(f -> "file".equals(f.getFileType()) || "url".equals(f.getFileType())));
System.out.println("sohehe4 dir=" + dirId + " entries=" + files.size());
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);
}
}
+3 -3
View File
@@ -17,7 +17,7 @@
</modules>
<properties>
<revision>0.4.1</revision>
<revision>0.4.2</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.6</jackson.version>
<jackson.version>2.18.9</jackson.version>
<!-- Logback 最新稳定版 -->
<logback.version>1.5.32</logback.version>
<logback.version>1.5.34</logback.version>
<junit.version>4.13.2</junit.version>
</properties>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "nfd-web",
"version": "0.4.1",
"version": "0.4.2",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
@@ -13,7 +13,7 @@
"@element-plus/icons-vue": "^2.3.1",
"@monaco-editor/loader": "^1.4.0",
"@vueuse/core": "^11.2.0",
"axios": "1.16.1",
"axios": "1.18.0",
"clipboard": "^2.0.11",
"core-js": "^3.8.3",
"crypto-js": "^4.2.0",
+194 -56
View File
@@ -24,7 +24,7 @@
:class="[getFileTypeClass(file), { 'selected': batchMode && isFileSelected(file) }]"
@click="batchMode ? onBatchClick(file) : handleFileClick(file)"
>
<div v-if="batchMode && file.fileType !== 'folder'" class="batch-checkbox" @click.stop="toggleFileSelect(file)">
<div v-if="batchMode && isDownloadableFile(file)" 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,9 +52,10 @@
<div class="batch-right">
<el-button
type="primary" size="small"
:disabled="selectedFiles.length === 0"
:disabled="selectedFiles.length === 0 || batchBrowserDownloadDisabled"
:loading="batchDownloading"
@click="batchBrowserDownload"
:title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''"
>
<i class="fas fa-download"></i> 浏览器下载
</el-button>
@@ -127,9 +128,10 @@
<el-button
type="primary"
size="small"
:disabled="selectedFiles.length === 0 || batchDownloading"
:disabled="selectedFiles.length === 0 || batchDownloading || batchBrowserDownloadDisabled"
:loading="batchDownloading"
@click="batchBrowserDownload"
:title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''"
>
浏览器下载
</el-button>
@@ -166,25 +168,41 @@
</div>
<h4 class="file-detail-name">{{ selectedNode.fileName }}</h4>
<div v-if="selectedNode.fileType !== 'folder'" class="file-detail-meta">
<p>类型: {{ getFileTypeClass(selectedNode) }}</p>
<p>大小: {{ selectedNode.sizeStr || '0B' }}</p>
<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 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.parserUrl" size="small" @click="previewFile(selectedNode)">
<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)"
>
<i class="fas fa-external-link-alt"></i> 打开
</el-button>
<el-button
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
v-if="isDownloadableFile(selectedNode)"
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="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
v-if="isDownloadableFile(selectedNode)"
type="primary" size="small"
@click="sendSingleToDownloader(selectedNode)"
:loading="singleSendLoading"
@@ -192,10 +210,12 @@
<i class="fas fa-paper-plane"></i> 发送到下载器
</el-button>
<el-button
v-if="selectedNode.parserUrl"
v-if="isDownloadableFile(selectedNode)"
size="small"
@click="copyDirectLink(selectedNode)"
:loading="copyLinkLoading"
:disabled="needsDownloader(selectedNode)"
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器,无法直接复制直链' : ''"
>
<i class="fas fa-link"></i> 复制直链
</el-button>
@@ -243,25 +263,41 @@
</div>
<h4 class="file-detail-name">{{ selectedNode.fileName }}</h4>
<div v-if="selectedNode.fileType !== 'folder'" class="file-detail-meta">
<p>类型: {{ getFileTypeClass(selectedNode) }}</p>
<p>大小: {{ selectedNode.sizeStr || '0B' }}</p>
<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 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.parserUrl" size="small" @click="previewFile(selectedNode)">
<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)"
>
<i class="fas fa-external-link-alt"></i> 打开
</el-button>
<el-button
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
v-if="isDownloadableFile(selectedNode)"
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="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
v-if="isDownloadableFile(selectedNode)"
type="primary" size="small"
@click="sendSingleToDownloader(selectedNode)"
:loading="singleSendLoading"
@@ -269,10 +305,12 @@
<i class="fas fa-paper-plane"></i> 发送到下载器
</el-button>
<el-button
v-if="selectedNode.parserUrl"
v-if="isDownloadableFile(selectedNode)"
size="small"
@click="copyDirectLink(selectedNode)"
:loading="copyLinkLoading"
:disabled="needsDownloader(selectedNode)"
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器,无法直接复制直链' : ''"
>
<i class="fas fa-link"></i> 复制直链
</el-button>
@@ -290,7 +328,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" :loading="batchDownloading" @click="batchBrowserDownload">浏览器下载</el-button>
<el-button type="primary" size="small" :disabled="selectedFiles.length === 0 || batchDownloading || batchBrowserDownloadDisabled" :loading="batchDownloading" @click="batchBrowserDownload" :title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''">浏览器下载</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>
@@ -323,19 +361,22 @@
</div>
<span slot="footer" class="dialog-footer">
<el-button type="primary" @click="previewFile(selectedFile)">打开</el-button>
<!-- 弹窗下载按钮 -->
<el-button type="primary" @click="previewFile(selectedFile)">
{{ selectedFile?.fileType === 'url' ? '打开链接' : '打开' }}
</el-button>
<el-button
v-if="selectedFile && selectedFile.parserUrl"
v-if="isDownloadableFile(selectedFile)"
type="success"
@click="handleDownload(selectedFile)"
style="margin-left: 8px;"
:loading="downloadLoading"
:disabled="needsDownloader(selectedFile)"
:title="needsDownloader(selectedFile) ? '该网盘需使用下载器下载' : ''"
>
下载
</el-button>
<el-button
v-if="selectedFile && selectedFile.parserUrl"
v-if="isDownloadableFile(selectedFile)"
type="primary"
@click="sendSingleToDownloader(selectedFile)"
style="margin-left: 8px;"
@@ -344,10 +385,12 @@
发送到下载器
</el-button>
<el-button
v-if="selectedFile && selectedFile.parserUrl"
v-if="isDownloadableFile(selectedFile)"
@click="copyDirectLink(selectedFile)"
style="margin-left: 8px;"
:loading="copyLinkLoading"
:disabled="needsDownloader(selectedFile)"
:title="needsDownloader(selectedFile) ? '该网盘需使用下载器,无法直接复制直链' : ''"
>
复制直链
</el-button>
@@ -394,6 +437,11 @@ export default {
type: String,
default: ''
},
// 加密 auth,用于子目录/下载请求透传(后端未带上时前端补齐)
auth: {
type: String,
default: ''
},
viewMode: {
type: String,
default: 'pane' // 'pane' or 'tree'
@@ -460,6 +508,11 @@ export default {
lines.push(`更新时间: ${updateTime}`)
}
return lines
},
// 所选文件全部需要下载器时,禁用浏览器批量下载
batchBrowserDownloadDisabled() {
return this.selectedFiles.length > 0
&& this.selectedFiles.every(f => this.needsDownloader(f))
}
},
watch: {
@@ -493,6 +546,13 @@ 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({
@@ -501,14 +561,14 @@ export default {
if (this.password) {
params.append('pwd', this.password)
}
return `${baseUrl}?${params.toString()}`
return this.withAuth(`${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(node.data.parserUrl, { headers: this.apiKeyHeaders() }).then(res => {
axios.get(this.withAuth(node.data.parserUrl), { headers: this.apiKeyHeaders() }).then(res => {
if (res.data.code === 200) {
const children = (res.data.data || []).map(item => ({
...item,
@@ -535,6 +595,8 @@ 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
@@ -548,10 +610,11 @@ export default {
}
try {
this.loading = true
const response = await axios.get(folder.parserUrl, { headers: this.apiKeyHeaders() })
const folderUrl = this.withAuth(folder.parserUrl)
const response = await axios.get(folderUrl, { headers: this.apiKeyHeaders() })
if (response.data.code === 200) {
const newDir = {
url: folder.parserUrl,
url: folderUrl,
name: folder.fileName || '未命名文件夹'
}
this.pathStack.push(newDir)
@@ -585,7 +648,7 @@ export default {
}
try {
this.loading = true
const response = await axios.get(currentDir.url, { headers: this.apiKeyHeaders() })
const response = await axios.get(this.withAuth(currentDir.url), { headers: this.apiKeyHeaders() })
if (response.data.code === 200) {
this.currentFileList = response.data.data || []
} else {
@@ -599,8 +662,32 @@ 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
@@ -650,25 +737,18 @@ export default {
// 需要下载器,调用 getFileDownInfo 接口获取下载信息
this.downloadLoading = true
try {
// 从 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) {
const tp = this.extractTypeParam(file)
if (!tp) {
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(`${window.location.origin}/v2/getFileDownInfo/${type}/${param}`, { headers })
const response = await axios.get(this.buildFileDownInfoUrl(tp.type, tp.param), { headers })
if (response.data.code === 200 && response.data.data) {
const info = response.data.data
if (info.needDownloader) {
@@ -759,10 +839,7 @@ export default {
const tp = this.extractTypeParam(file)
if (tp && file.extParameters && file.extParameters.needDownloader) {
const headers = this.apiKeyHeaders()
const resp = await axios.get(
`${window.location.origin}/v2/getFileDownInfo/${tp.type}/${tp.param}`,
{ headers }
)
const resp = await axios.get(this.buildFileDownInfoUrl(tp.type, tp.param), { headers })
const info = resp.data.data || resp.data
if (info && info.downloadUrl) {
await addDownload(info.downloadUrl, info.downloadHeaders || {}, file.fileName)
@@ -870,7 +947,9 @@ export default {
},
fileMetaText(file) {
const parts = []
if (file.fileType !== 'folder') {
if (file.fileType === 'url') {
parts.push('超链接')
} else if (file.fileType !== 'folder') {
parts.push(file.sizeStr || '0B')
}
const timeText = this.formatDate(file.createTime)
@@ -916,7 +995,7 @@ export default {
this.toggleFileSelect(file)
},
selectAll() {
this.selectedFiles = this.currentFileList.filter(f => f.fileType !== 'folder' && f.parserUrl)
this.selectedFiles = this.currentFileList.filter(f => this.isDownloadableFile(f))
},
deselectAll() {
this.selectedFiles = []
@@ -924,25 +1003,41 @@ export default {
onTreeCheckChange() {
if (!this.$refs.fileTree) return
const checked = this.$refs.fileTree.getCheckedNodes()
this.selectedFiles = checked.filter(n => n.fileType !== 'folder' && n.parserUrl)
this.selectedFiles = checked.filter(n => this.isDownloadableFile(n))
},
extractTypeParam(file) {
if (!file.parserUrl) return null
try {
// pathname 已是解码后的 path;后端现用 URL-Safe Base64(无 %),可直接使用
const url = new URL(file.parserUrl, window.location.origin)
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] }
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 }
} catch {
return null
}
},
// URL-Safe Base64 本身可进 pathencodeURIComponent 对 - _ 无影响,只 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: this.selectedFiles.length, failed: 0 }
for (const file of this.selectedFiles) {
this.batchProgress = { current: 0, total: files.length, failed: 0 }
for (const file of files) {
try {
const a = document.createElement('a')
const rawUrl = file.parserUrl.startsWith('http') ? file.parserUrl : (window.location.origin + file.parserUrl)
@@ -975,13 +1070,32 @@ export default {
const total = this.selectedFiles.length
this.batchProgress = { current: 0, total, failed: 0 }
// 所有文件统一发 parserUrl(302) + downloadHeaders 给下载器
// needDownloader 文件走 getFileDownInfo 拿直链+cookie;其余发 parserUrl + headers
const downloadTasks = []
const apiHeaders = this.apiKeyHeaders()
for (const file of this.selectedFiles) {
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 })
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++
}
this.batchProgress.current++
}
@@ -1005,14 +1119,18 @@ 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, 'file-icon': !isFolder }]
class: [
this.getFileIcon(data),
{ 'folder-icon': isFolder, 'url-icon': isUrl, 'file-icon': !isFolder && !isUrl }
]
}),
h('span', {
class: ['node-label', { 'folder-text': isFolder, 'file-text': !isFolder }]
class: ['node-label', { 'folder-text': isFolder, 'url-text': isUrl, 'file-text': !isFolder && !isUrl }]
}, node.label)
])
}
@@ -1193,6 +1311,10 @@ 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;
@@ -1438,6 +1560,22 @@ 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;
+7
View File
@@ -356,6 +356,13 @@
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\/.+/,
+3
View File
@@ -5,6 +5,7 @@ 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'],
@@ -23,6 +24,8 @@ 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',
+46 -15
View File
@@ -131,10 +131,15 @@
<div style="display: flex; align-items: center; justify-content: space-between;">
<span>下载链接</span>
<div style="display: flex; gap: 8px;">
<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-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-icon style="margin-right: 4px;"><View /></el-icon> 预览
</el-button>
<el-tooltip :disabled="aria2Connected"
@@ -150,10 +155,14 @@
</template>
<el-input :value="downloadUrl" readonly>
<template #append>
<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>
<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>
</template>
</el-input>
<!-- 文件元信息 -->
@@ -417,6 +426,7 @@
:file-list="directoryData"
:share-url="link"
:password="password"
:auth="directoryAuth"
:view-mode="directoryViewMode"
@file-click="handleFileClick"
/>
@@ -649,6 +659,7 @@ export default {
// 目录树
showDirectoryTree: false,
directoryData: [],
directoryAuth: '', // 目录解析时的加密 auth,透传给子目录/下载
// 统计信息
node1Info: {},
@@ -760,6 +771,16 @@ 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: {
@@ -1168,6 +1189,7 @@ export default {
this.statisticsData = {}
this.showDirectoryTree = false
this.directoryData = []
this.directoryAuth = ''
},
// 统一API调用(自动添加认证参数)
@@ -1176,10 +1198,12 @@ export default {
this.errorBadgeVisible = false
try {
this.isLoading = true
// 添加认证参数(异步获取
const authParam = await this.generateAuthParam()
if (authParam) {
params.auth = authParam
// 添加认证参数(已有则不覆盖,便于目录树透传同一份 auth
if (!params.auth) {
const authParam = await this.generateAuthParam()
if (authParam) {
params.auth = authParam
}
}
const response = await axios.get(`${this.baseAPI}${endpoint}`, { params })
@@ -1267,11 +1291,13 @@ export default {
// 更新智能直链(包含认证参数)
this.updateDirectLink()
// 如果需要下载器(含特殊头),弹出下载器对话框
if (result.data?.needDownloader) {
const needDownloader = !!(result.data?.needDownloader || otherParam.needDownloader
|| otherParam.downloadHeaders?.cookie || otherParam.downloadHeaders?.Cookie)
if (needDownloader) {
this.downloadDialogInfo = {
downloadUrl: result.data.directLink,
fileName: result.data.fileName || '',
downloadHeaders: result.data.downloadHeaders || {},
fileName: result.data.fileInfo?.fileName || result.data.fileName || '',
downloadHeaders: result.data.downloadHeaders || otherParam.downloadHeaders || {},
aria2Command: this.aria2Command,
curlCommand: this.curlCommand,
aria2JsonRpc: this.aria2JsonRpc,
@@ -1291,6 +1317,11 @@ 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)
+10
View File
@@ -386,6 +386,16 @@
"type": "string",
"example": "uuid123"
}
},
{
"name": "stoken",
"in": "query",
"required": false,
"description": "分享 token,用于子目录解析时复用认证状态",
"schema": {
"type": "string",
"example": "OASBe5qM0pg2VvvyLM..."
}
}
],
"responses": {
@@ -22,6 +22,8 @@ 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() {
}
@@ -41,6 +43,8 @@ public class ParserAuthUtil {
}
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());
@@ -68,9 +68,10 @@ public class URLParamUtil {
boolean firstParam = !decodedUrl.contains("?");
for (String paramName : params.names()) {
// 忽略 "url", "pwd", "dirId", "uuid", "auth" 参数(这些参数单独处理,不应拼接到分享URL中
if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId")
&& !paramName.equals("uuid") && !paramName.equals("auth")) {
// 忽略单独处理的参数,不应拼接到分享URL中
if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId")
&& !paramName.equals("uuid") && !paramName.equals("auth")
&& !paramName.equals("stoken") && !paramName.equals("zml")) {
if (firstParam) {
urlBuilder.append("?");
firstParam = false;
@@ -16,7 +16,9 @@ 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;
@@ -161,7 +163,7 @@ public class ParserApi {
@RouteMapping("/getFileList")
public Future<List<FileInfo>> getFileList(HttpServerRequest request, String pwd, String dirId, String uuid,
String auth) {
String stoken, String zml, String auth) {
String url = URLParamUtil.parserParams(request);
ParserCreate parserCreate;
try {
@@ -176,9 +178,15 @@ public class ParserApi {
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);
@@ -204,7 +212,14 @@ public class ParserApi {
return promise.future();
}
String paramStr = new String(Base64.getDecoder().decode(param));
final String paramStr;
try {
paramStr = CommonUtils.urlBase64Decode(param);
} catch (Exception e) {
Promise<String> promise = Promise.promise();
promise.fail("下载参数解码失败: " + e.getMessage());
return promise.future();
}
ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo();
shareLinkInfo.getOtherParam().put("paramJson", new JsonObject(paramStr));
@@ -239,6 +254,131 @@ 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);
}
}
}
/**
* 预览媒体文件
@@ -186,6 +186,11 @@ 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);