Compare commits

...
Author SHA1 Message Date
qaiu e9be9b3345 Merge pull request #259 from qaiu/fix/iz-auth-fallback
fix(iz): 蓝奏优享登录失败回退免登录 + release 0.4.8
2026-10-14 04:53:24 +08:00
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
qaiu ab8b687837 Merge pull request #231 from qaiu/cursor/release-047-9e8b
release: v0.4.7
2026-09-25 16:35:29 +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的路径改为实际路径 > 注意: netdisk-fast-download.service中的ExecStart的路径改为实际路径
```shell ```shell
cd ~ 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 unzip netdisk-fast-download.zip
cd netdisk-fast-download cd netdisk-fast-download
bash service-install.sh bash service-install.sh
@@ -3,6 +3,7 @@ package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo; import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo; import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase; import cn.qaiu.parser.PanBase;
import cn.qaiu.parser.TokenCache;
import cn.qaiu.util.AESUtils; import cn.qaiu.util.AESUtils;
import cn.qaiu.util.AcwScV2Generator; import cn.qaiu.util.AcwScV2Generator;
import cn.qaiu.util.CommonUtils; import cn.qaiu.util.CommonUtils;
@@ -85,12 +86,31 @@ public class IzTool extends PanBase {
} }
public IzTool(ShareLinkInfo shareLinkInfo) { public IzTool(ShareLinkInfo shareLinkInfo) {
super(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() String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
public static volatile String token = null; // 多账号隔离:用 TokenCache 代替 static 变量
public static volatile boolean authFlag = true; private final String cacheKey;
private String token() {
return TokenCache.getToken(cacheKey);
}
private boolean authFlag() {
return TokenCache.getFlag(cacheKey, true);
}
public Future<String> parse() { public Future<String> parse() {
@@ -101,22 +121,21 @@ public class IzTool extends PanBase {
// 检查并输出认证状态 // 检查并输出认证状态
if (shareLinkInfo.getOtherParam().containsKey("auths")) { if (shareLinkInfo.getOtherParam().containsKey("auths")) {
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED"); boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
String token = token();
log.info("文件解析检测到认证信息: isTempAuth={}, authFlag={}, 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,先执行登录 // 如果需要认证但还没有token,先执行登录
if ((isTempAuth || authFlag) && token == null) { if ((isTempAuth || authFlag()) && token == null) {
log.info("文件解析需要登录,开始执行登录流程..."); log.info("文件解析需要登录,开始执行登录流程...");
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
return login(tsEncode, auths) return login(tsEncode, auths)
.compose(v -> { .recover(err -> {
log.info("文件解析预登录成功,继续解析流程"); // 预登录失败不终止解析,继续走免登录
return parseWithAuth(shareId, tsEncode); log.warn("文件解析预登录失败: {},使用免登录模式", err.getMessage());
return Future.succeededFuture();
}) })
.onFailure(err -> { .compose(v -> parseWithAuth(shareId, tsEncode));
log.warn("文件解析预登录失败: {},尝试使用免登录模式", err.getMessage());
// 登录失败,继续使用免登录模式
});
} else if (token != null) { } else if (token != null) {
log.info("文件解析使用已有token: {}...", token.substring(0, Math.min(8, token.length()))); 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"); boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
// 如果是临时认证,或者是后台配置且authFlag为true,则尝试使用认证 // 如果是临时认证,或者是后台配置且authFlag为true,则尝试使用认证
if (isTempAuth || authFlag) { if (isTempAuth || authFlag()) {
log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag); log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag());
HttpRequest<Buffer> httpRequest = HttpRequest<Buffer> httpRequest =
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP)) webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
.setTemplateParam("fidEncode", fidEncode) .setTemplateParam("fidEncode", fidEncode)
@@ -241,18 +260,19 @@ public class IzTool extends PanBase {
.setTemplateParam("auth", auth) .setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey); .setTemplateParam("dataKey", dataKey);
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
if (token == null) { if (token() == null) {
// 执行登录 // 执行登录,失败则回退免登录
login(tsEncode2, auths).onFailure(failRes-> { login(tsEncode2, auths).onFailure(failRes-> {
log.warn("登录失败: {}", failRes.getMessage()); log.warn("登录失败,回退免登录: {}", failRes.getMessage());
fail(failRes.getMessage()); downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}).onSuccess(r-> { }).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token) httpRequest.setTemplateParam("appToken", token())
.putHeaders(header); .putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2")); httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
}); });
} else { } else {
// 验证token // 验证token — 先从缓存恢复到 header
header.set("appToken", token());
webClientSession.postAbs(UriTemplate.of(TOKEN_VERIFY_URL)) webClientSession.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
.setTemplateParam("uuid", uuid) .setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2) .setTemplateParam("ts", tsEncode2)
@@ -260,15 +280,15 @@ public class IzTool extends PanBase {
// log.info("res: {}",asJson(res)); // log.info("res: {}",asJson(res));
if (asJson(res).getInteger("code") != 200) { if (asJson(res).getInteger("code") != 200) {
login(tsEncode2, auths).onFailure(failRes -> { login(tsEncode2, auths).onFailure(failRes -> {
log.warn("重新登录失败: {}", failRes.getMessage()); log.warn("重新登录失败,回退免登录: {}", failRes.getMessage());
fail(failRes.getMessage()); downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}).onSuccess(r-> { }).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token) httpRequest.setTemplateParam("appToken", token())
.putHeaders(header); .putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2")); httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
}); });
} else { } else {
httpRequest.setTemplateParam("appToken", token) httpRequest.setTemplateParam("appToken", token())
.putHeaders(header); .putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2")); httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
} }
@@ -277,18 +297,17 @@ public class IzTool extends PanBase {
} else { } else {
// authFlag 为 false,使用免登录解析 // authFlag 为 false,使用免登录解析
log.debug("authFlag=false,使用免登录解析"); log.debug("authFlag=false,使用免登录解析");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL)) downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
} }
} else { } else {
// 没有认证信息,使用免登录解析 // 没有认证信息,使用免登录解析
log.debug("无认证信息,使用免登录解析"); log.debug("无认证信息,使用免登录解析");
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)) webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header) .putHeaders(header)
.setTemplateParam("fidEncode", fidEncode) .setTemplateParam("fidEncode", fidEncode)
@@ -298,7 +317,6 @@ public class IzTool extends PanBase {
.setTemplateParam("dataKey", dataKey).send() .setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2")); .onSuccess(this::down).onFailure(handleFail("请求2"));
} }
}
private Future<Void> login(String tsEncode2, MultiMap auths) { private Future<Void> login(String tsEncode2, MultiMap auths) {
Promise<Void> promise1 = Promise.promise(); Promise<Void> promise1 = Promise.promise();
@@ -310,8 +328,13 @@ public class IzTool extends PanBase {
.onSuccess(res2->{ .onSuccess(res2->{
JsonObject json = asJson(res2); JsonObject json = asJson(res2);
if (json.getInteger("code") == 200) { if (json.getInteger("code") == 200) {
token = json.getJsonObject("data").getString("appToken"); String loginToken = json.getJsonObject("data").getString("appToken");
log.info("登录成功 token: {}...", token != null ? token.substring(0, Math.min(8, token.length())) : "null"); 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(); promise1.complete();
} else { } else {
// 检查是否为临时认证 // 检查是否为临时认证
@@ -323,7 +346,7 @@ public class IzTool extends PanBase {
} else { } else {
// 后台配置的认证失败,设置authFlag并返回失败,让下次请求使用免登陆解析 // 后台配置的认证失败,设置authFlag并返回失败,让下次请求使用免登陆解析
log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg")); log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg"));
authFlag = false; TokenCache.putFlag(cacheKey, false);
promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式"); promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式");
} }
} }
@@ -691,7 +714,6 @@ public class IzTool extends PanBase {
} }
public static void resetToken() { public static void resetToken() {
token = null; TokenCache.removeByDiskType("iz");
authFlag = true;
} }
} }
@@ -84,6 +84,7 @@ public class IzToolWithAuth extends PanBase {
} }
public IzToolWithAuth(ShareLinkInfo shareLinkInfo) { public IzToolWithAuth(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo); super(shareLinkInfo);
IzXffHeader.apply(header);
} }
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString() String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
@@ -213,8 +214,8 @@ public class IzToolWithAuth extends PanBase {
if (token == null) { if (token == null) {
// 执行登录 // 执行登录
login(tsEncode2, auths).onFailure(failRes-> { login(tsEncode2, auths).onFailure(failRes-> {
log.warn("登录失败: {}", failRes.getMessage()); log.warn("登录失败,回退免登录: {}", failRes.getMessage());
fail(failRes.getMessage()); downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}).onSuccess(r-> { }).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token) httpRequest.setTemplateParam("appToken", token)
.putHeaders(header); .putHeaders(header);
@@ -229,8 +230,8 @@ public class IzToolWithAuth extends PanBase {
// log.info("res: {}",asJson(res)); // log.info("res: {}",asJson(res));
if (asJson(res).getInteger("code") != 200) { if (asJson(res).getInteger("code") != 200) {
login(tsEncode2, auths).onFailure(failRes -> { login(tsEncode2, auths).onFailure(failRes -> {
log.warn("重新登录失败: {}", failRes.getMessage()); log.warn("重新登录失败,回退免登录: {}", failRes.getMessage());
fail(failRes.getMessage()); downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}).onSuccess(r-> { }).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token) httpRequest.setTemplateParam("appToken", token)
.putHeaders(header); .putHeaders(header);
@@ -246,18 +247,17 @@ public class IzToolWithAuth extends PanBase {
} else { } else {
// authFlag 为 false,使用免登录解析 // authFlag 为 false,使用免登录解析
log.debug("authFlag=false,使用免登录解析"); log.debug("authFlag=false,使用免登录解析");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL)) downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
} }
} else { } else {
// 没有认证信息,使用免登录解析 // 没有认证信息,使用免登录解析
log.debug("无认证信息,使用免登录解析"); log.debug("无认证信息,使用免登录解析");
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)) webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header) .putHeaders(header)
.setTemplateParam("fidEncode", fidEncode) .setTemplateParam("fidEncode", fidEncode)
@@ -267,7 +267,6 @@ public class IzToolWithAuth extends PanBase {
.setTemplateParam("dataKey", dataKey).send() .setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2")); .onSuccess(this::down).onFailure(handleFail("请求2"));
} }
}
private Future<Void> login(String tsEncode2, MultiMap auths) { private Future<Void> login(String tsEncode2, MultiMap auths) {
Promise<Void> promise1 = Promise.promise(); Promise<Void> promise1 = Promise.promise();
@@ -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> </modules>
<properties> <properties>
<revision>0.4.7</revision> <revision>0.4.8</revision>
<java.version>17</java.version> <java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nfd-web", "name": "nfd-web",
"version": "0.4.7", "version": "0.4.8",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",