Compare commits

..
Author SHA1 Message Date
qaiu 6acb774c87 release: 0.4.8 Youxiang auth fallback and configurable XFF 2026-10-14 04:38:00 +08:00
qaiu 511515cae4 fix(iz): 蓝奏优享登录失败回退免登录、TokenCache 多账号、可配置 XFF
- IzTool.parse 预登录失败 recover 后继续免登录解析
- IzTool / IzToolWithAuth 登录或重登失败回退匿名下载(downloadAnonymous)
- IzTool 改用 TokenCache(按 _configId/username 隔离),校验 token 前恢复 appToken 头
- 新增 IzXffHeader:-Dnfd.iz.xff / NFD_IZ_XFF = off(默认)/random/固定IP
2026-10-14 04:38:00 +08:00
7 changed files with 253 additions and 136 deletions
+1 -1
View File
@@ -461,7 +461,7 @@ 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.7/netdisk-fast-download-linux-amd64.zip
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v0.4.8/netdisk-fast-download-linux-amd64.zip
unzip netdisk-fast-download.zip
cd netdisk-fast-download
bash service-install.sh
@@ -3,6 +3,7 @@ package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
import cn.qaiu.parser.TokenCache;
import cn.qaiu.util.AESUtils;
import cn.qaiu.util.AcwScV2Generator;
import cn.qaiu.util.CommonUtils;
@@ -85,12 +86,31 @@ public class IzTool extends PanBase {
}
public IzTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
IzXffHeader.apply(header);
String accountId = "_default";
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
if (auths.contains("_configId")) {
accountId = auths.get("_configId");
} else if (auths.contains("username")) {
accountId = auths.get("username");
}
}
this.cacheKey = TokenCache.key("iz", accountId);
}
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
public static volatile String token = null;
public static volatile boolean authFlag = true;
// 多账号隔离:用 TokenCache 代替 static 变量
private final String cacheKey;
private String token() {
return TokenCache.getToken(cacheKey);
}
private boolean authFlag() {
return TokenCache.getFlag(cacheKey, true);
}
public Future<String> parse() {
@@ -101,22 +121,21 @@ public class IzTool extends PanBase {
// 检查并输出认证状态
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
String token = token();
log.info("文件解析检测到认证信息: isTempAuth={}, authFlag={}, token={}",
isTempAuth, authFlag, token != null ? "已登录(" + token.substring(0, Math.min(8, token.length())) + "...)" : "未登录");
isTempAuth, authFlag(), token != null ? "已登录(" + token.substring(0, Math.min(8, token.length())) + "...)" : "未登录");
// 如果需要认证但还没有token,先执行登录
if ((isTempAuth || authFlag) && token == null) {
if ((isTempAuth || authFlag()) && token == null) {
log.info("文件解析需要登录,开始执行登录流程...");
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
return login(tsEncode, auths)
.compose(v -> {
log.info("文件解析预登录成功,继续解析流程");
return parseWithAuth(shareId, tsEncode);
.recover(err -> {
// 预登录失败不终止解析,继续走免登录
log.warn("文件解析预登录失败: {},使用免登录模式", err.getMessage());
return Future.succeededFuture();
})
.onFailure(err -> {
log.warn("文件解析预登录失败: {},尝试使用免登录模式", err.getMessage());
// 登录失败,继续使用免登录模式
});
.compose(v -> parseWithAuth(shareId, tsEncode));
} else if (token != null) {
log.info("文件解析使用已有token: {}...", token.substring(0, Math.min(8, token.length())));
}
@@ -231,8 +250,8 @@ public class IzTool extends PanBase {
// 检查是否为临时认证(临时认证每次都尝试登录)
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
// 如果是临时认证,或者是后台配置且authFlag为true,则尝试使用认证
if (isTempAuth || authFlag) {
log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag);
if (isTempAuth || authFlag()) {
log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag());
HttpRequest<Buffer> httpRequest =
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
.setTemplateParam("fidEncode", fidEncode)
@@ -241,18 +260,19 @@ public class IzTool extends PanBase {
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey);
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
if (token == null) {
// 执行登录
if (token() == null) {
// 执行登录,失败则回退免登录
login(tsEncode2, auths).onFailure(failRes-> {
log.warn("登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
log.warn("登录失败,回退免登录: {}", failRes.getMessage());
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token)
httpRequest.setTemplateParam("appToken", token())
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
});
} else {
// 验证token
// 验证token — 先从缓存恢复到 header
header.set("appToken", token());
webClientSession.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
@@ -260,15 +280,15 @@ public class IzTool extends PanBase {
// log.info("res: {}",asJson(res));
if (asJson(res).getInteger("code") != 200) {
login(tsEncode2, auths).onFailure(failRes -> {
log.warn("重新登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
log.warn("重新登录失败,回退免登录: {}", failRes.getMessage());
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token)
httpRequest.setTemplateParam("appToken", token())
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
});
} else {
httpRequest.setTemplateParam("appToken", token)
httpRequest.setTemplateParam("appToken", token())
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
}
@@ -277,29 +297,27 @@ public class IzTool extends PanBase {
} else {
// authFlag 为 false,使用免登录解析
log.debug("authFlag=false,使用免登录解析");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}
} else {
// 没有认证信息,使用免登录解析
log.debug("无认证信息,使用免登录解析");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}
}
private void downloadAnonymous(String fidEncode, String tsEncode2, String auth, String dataKey) {
log.debug("使用免登录解析");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
}
private Future<Void> login(String tsEncode2, MultiMap auths) {
Promise<Void> promise1 = Promise.promise();
webClientSession.postAbs(UriTemplate.of(LOGIN_URL))
@@ -310,8 +328,13 @@ public class IzTool extends PanBase {
.onSuccess(res2->{
JsonObject json = asJson(res2);
if (json.getInteger("code") == 200) {
token = json.getJsonObject("data").getString("appToken");
log.info("登录成功 token: {}...", token != null ? token.substring(0, Math.min(8, token.length())) : "null");
String loginToken = json.getJsonObject("data").getString("appToken");
TokenCache.putToken(cacheKey, loginToken);
TokenCache.queueCachedTokenPersist(cacheKey, loginToken, System.currentTimeMillis() + 24 * 60 * 60 * 1000L);
if (loginToken != null) {
header.set("appToken", loginToken);
}
log.info("登录成功 token: {}...", loginToken != null ? loginToken.substring(0, Math.min(8, loginToken.length())) : "null");
promise1.complete();
} else {
// 检查是否为临时认证
@@ -323,7 +346,7 @@ public class IzTool extends PanBase {
} else {
// 后台配置的认证失败,设置authFlag并返回失败,让下次请求使用免登陆解析
log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg"));
authFlag = false;
TokenCache.putFlag(cacheKey, false);
promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式");
}
}
@@ -443,75 +466,75 @@ public class IzTool extends PanBase {
}
}
private void down(HttpResponse<Buffer> res2) {
MultiMap headers = res2.headers();
String location = headers.get("Location");
if (StringUtils.isBlank(location)) {
fail("{}", buildMissingLocationMessage(res2));
return;
}
promise.complete(location);
}
private String buildMissingLocationMessage(HttpResponse<Buffer> response) {
StringBuilder message = new StringBuilder("未获取到下载重定向地址");
message.append(", HTTP ").append(response.statusCode());
String body = null;
try {
body = asText(response);
} catch (Exception e) {
body = "<响应体读取失败: " + e.getMessage() + ">";
}
if (StringUtils.isNotBlank(body)) {
try {
JsonObject json = new JsonObject(body);
String upstreamMsg = json.getString("msg");
Object code = json.getValue("code");
if (StringUtils.isNotBlank(upstreamMsg)) {
message.append(", 上游返回: ").append(upstreamMsg);
if (code != null) {
message.append(" (code=").append(code).append(")");
}
} else {
message.append(", 响应体: ").append(previewBody(body));
}
} catch (Exception ignored) {
message.append(", 响应体: ").append(previewBody(body));
}
} else {
message.append(", 响应体为空");
}
Object fileName = shareLinkInfo.getOtherParam().get("fileName");
Object fileSize = shareLinkInfo.getOtherParam().get("fileSizeFormat");
if (fileName != null) {
message.append(", 文件: ").append(fileName);
}
if (fileSize != null) {
message.append(", 大小: ").append(fileSize);
}
if (!hasConfiguredAuth()) {
message.append(", 当前为免登录解析,上游可能要求登录、会员或人工处理");
}
return message.toString();
}
private boolean hasConfiguredAuth() {
Object authObj = shareLinkInfo.getOtherParam().get("auths");
if (!(authObj instanceof MultiMap auths)) {
return false;
}
return StringUtils.isNotBlank(auths.get("username")) && StringUtils.isNotBlank(auths.get("password"));
}
private String previewBody(String body) {
int maxLength = 500;
return body.length() <= maxLength ? body : body.substring(0, maxLength) + "...";
}
// 目录解析
private void down(HttpResponse<Buffer> res2) {
MultiMap headers = res2.headers();
String location = headers.get("Location");
if (StringUtils.isBlank(location)) {
fail("{}", buildMissingLocationMessage(res2));
return;
}
promise.complete(location);
}
private String buildMissingLocationMessage(HttpResponse<Buffer> response) {
StringBuilder message = new StringBuilder("未获取到下载重定向地址");
message.append(", HTTP ").append(response.statusCode());
String body = null;
try {
body = asText(response);
} catch (Exception e) {
body = "<响应体读取失败: " + e.getMessage() + ">";
}
if (StringUtils.isNotBlank(body)) {
try {
JsonObject json = new JsonObject(body);
String upstreamMsg = json.getString("msg");
Object code = json.getValue("code");
if (StringUtils.isNotBlank(upstreamMsg)) {
message.append(", 上游返回: ").append(upstreamMsg);
if (code != null) {
message.append(" (code=").append(code).append(")");
}
} else {
message.append(", 响应体: ").append(previewBody(body));
}
} catch (Exception ignored) {
message.append(", 响应体: ").append(previewBody(body));
}
} else {
message.append(", 响应体为空");
}
Object fileName = shareLinkInfo.getOtherParam().get("fileName");
Object fileSize = shareLinkInfo.getOtherParam().get("fileSizeFormat");
if (fileName != null) {
message.append(", 文件: ").append(fileName);
}
if (fileSize != null) {
message.append(", 大小: ").append(fileSize);
}
if (!hasConfiguredAuth()) {
message.append(", 当前为免登录解析,上游可能要求登录、会员或人工处理");
}
return message.toString();
}
private boolean hasConfiguredAuth() {
Object authObj = shareLinkInfo.getOtherParam().get("auths");
if (!(authObj instanceof MultiMap auths)) {
return false;
}
return StringUtils.isNotBlank(auths.get("username")) && StringUtils.isNotBlank(auths.get("password"));
}
private String previewBody(String body) {
int maxLength = 500;
return body.length() <= maxLength ? body : body.substring(0, maxLength) + "...";
}
// 目录解析
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> promise = Promise.promise();
@@ -691,7 +714,6 @@ public class IzTool extends PanBase {
}
public static void resetToken() {
token = null;
authFlag = true;
TokenCache.removeByDiskType("iz");
}
}
@@ -84,6 +84,7 @@ public class IzToolWithAuth extends PanBase {
}
public IzToolWithAuth(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
IzXffHeader.apply(header);
}
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
@@ -213,8 +214,8 @@ public class IzToolWithAuth extends PanBase {
if (token == null) {
// 执行登录
login(tsEncode2, auths).onFailure(failRes-> {
log.warn("登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
log.warn("登录失败,回退免登录: {}", failRes.getMessage());
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
@@ -229,8 +230,8 @@ public class IzToolWithAuth extends PanBase {
// log.info("res: {}",asJson(res));
if (asJson(res).getInteger("code") != 200) {
login(tsEncode2, auths).onFailure(failRes -> {
log.warn("重新登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
log.warn("重新登录失败,回退免登录: {}", failRes.getMessage());
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
@@ -246,29 +247,27 @@ public class IzToolWithAuth extends PanBase {
} else {
// authFlag 为 false,使用免登录解析
log.debug("authFlag=false,使用免登录解析");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}
} else {
// 没有认证信息,使用免登录解析
log.debug("无认证信息,使用免登录解析");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}
}
private void downloadAnonymous(String fidEncode, String tsEncode2, String auth, String dataKey) {
log.debug("使用免登录解析");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
}
private Future<Void> login(String tsEncode2, MultiMap auths) {
Promise<Void> promise1 = Promise.promise();
webClientSession.postAbs(UriTemplate.of(LOGIN_URL))
@@ -0,0 +1,60 @@
package cn.qaiu.parser.impl;
import io.vertx.core.MultiMap;
import org.apache.commons.lang3.StringUtils;
import java.util.concurrent.ThreadLocalRandom;
/**
* 蓝奏云优享 X-Forwarded-For 配置。
* <p>
* 读取系统属性 {@code nfd.iz.xff},其次环境变量 {@code NFD_IZ_XFF}:
* <ul>
* <li>未设置 / 空 / {@code off}:不发送该头(默认)</li>
* <li>{@code random}:每次创建解析器时随机一个国内常见网段 IP</li>
* <li>其它值:原样作为头的值,例如 {@code 113.96.181.18}</li>
* </ul>
*/
public final class IzXffHeader {
public static final String PROP = "nfd.iz.xff";
public static final String ENV = "NFD_IZ_XFF";
private static final String HEADER = "X-Forwarded-For";
/** 国内运营商常见 /8 段首字节 */
private static final int[] CN_PREFIX = {36, 39, 42, 58, 59, 60, 61, 101, 106, 110, 111, 112, 113, 114,
115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 175, 180, 182, 183, 202, 210, 211, 218, 219, 220, 221, 222, 223};
private IzXffHeader() {
}
static String configured() {
String v = System.getProperty(PROP);
if (StringUtils.isBlank(v)) {
v = System.getenv(ENV);
}
return v == null ? "" : v.trim();
}
/** 根据配置得到头的值,返回 null 表示不发送 */
static String resolve(String mode) {
if (StringUtils.isBlank(mode) || "off".equalsIgnoreCase(mode)) {
return null;
}
if ("random".equalsIgnoreCase(mode)) {
ThreadLocalRandom r = ThreadLocalRandom.current();
return CN_PREFIX[r.nextInt(CN_PREFIX.length)] + "." + r.nextInt(256) + "." + r.nextInt(256) + "." + (1 + r.nextInt(254));
}
return mode;
}
/** 按当前配置写入或移除请求头 */
public static void apply(MultiMap headers) {
String value = resolve(configured());
if (value == null) {
headers.remove(HEADER);
} else {
headers.set(HEADER, value);
}
}
}
@@ -0,0 +1,36 @@
package cn.qaiu.parser.impl;
import io.vertx.core.MultiMap;
import org.junit.Test;
import static org.junit.Assert.*;
public class IzXffHeaderTest {
@Test
public void offByDefault() {
assertNull(IzXffHeader.resolve(null));
assertNull(IzXffHeader.resolve(""));
assertNull(IzXffHeader.resolve("OFF"));
}
@Test
public void literalAndRandom() {
assertEquals("113.96.181.18", IzXffHeader.resolve("113.96.181.18"));
String ip = IzXffHeader.resolve("random");
assertTrue(ip, ip.matches("\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"));
}
@Test
public void applyRemovesWhenOff() {
MultiMap h = MultiMap.caseInsensitiveMultiMap().set("X-Forwarded-For", "1.1.1.1");
System.clearProperty(IzXffHeader.PROP);
if (System.getenv(IzXffHeader.ENV) == null) {
IzXffHeader.apply(h);
assertFalse(h.contains("X-Forwarded-For"));
}
System.setProperty(IzXffHeader.PROP, "8.8.8.8");
IzXffHeader.apply(h);
assertEquals("8.8.8.8", h.get("X-Forwarded-For"));
System.clearProperty(IzXffHeader.PROP);
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
</modules>
<properties>
<revision>0.4.7</revision>
<revision>0.4.8</revision>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nfd-web",
"version": "0.4.7",
"version": "0.4.8",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",