Skip to content

Commit 8bd0638

Browse files
authored
Merge pull request #8 from mxzyTeam/feature/anti-hotlink
feat: 添加防盗链鉴权功能(阿里云ESA A方案)
2 parents 1c5aafa + 665775b commit 8bd0638

6 files changed

Lines changed: 436 additions & 7 deletions

File tree

src/main/java/com/github/balloonupdate/mcpatch/client/Work.java

100644100755
Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,11 @@ boolean run2(Servers server) throws IOException, McpatchBusinessException {
9292

9393
if (window != null) {
9494
window.setPhase(0);
95-
window.setLabelText("正在检测更新");
95+
if (config.antiHotlinkEnabled) {
96+
window.setLabelText("正在连接鉴权服务器...");
97+
} else {
98+
window.setLabelText("正在检测更新");
99+
}
96100
window.clearFileProgress();
97101
}
98102

@@ -422,7 +426,11 @@ boolean run2(Servers server) throws IOException, McpatchBusinessException {
422426

423427
if (window != null) {
424428
window.setPhase(2);
425-
window.setLabelText(String.format("正在下载更新文件 (%d 个文件)", updateFiles.size()));
429+
if (config.antiHotlinkEnabled) {
430+
window.setLabelText("正在获取下载凭据...");
431+
} else {
432+
window.setLabelText(String.format("正在下载更新文件 (%d 个文件)", updateFiles.size()));
433+
}
426434
window.clearFileProgress();
427435
}
428436

src/main/java/com/github/balloonupdate/mcpatch/client/config/AppConfig.java

100644100755
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,31 @@ public class AppConfig {
140140
*/
141141
public boolean enableChunkedDownload;
142142

143+
/**
144+
* 是否启用防盗链鉴权(阿里云ESA A方案)<p>
145+
* 启用后,下载文件时会先向鉴权服务器请求 auth_key,然后拼接到下载 URL 中<p>
146+
* 仅对 HTTP/HTTPS 协议生效,私有协议(mcpatch://)不受影响
147+
*/
148+
public boolean antiHotlinkEnabled;
149+
150+
/**
151+
* 鉴权 API 地址<p>
152+
* 用于生成符合阿里云ESA A方案规范的鉴权密钥(auth_key)
153+
*/
154+
public String authApiUrl;
155+
156+
/**
157+
* 鉴权 URL 有效时长,单位秒<p>
158+
* 建议与CDN端配置的鉴权有效时长保持一致,默认3600秒(1小时)
159+
*/
160+
public int authExpireTime;
161+
162+
/**
163+
* 鉴权用户ID<p>
164+
* 根据业务需求设置,默认为0
165+
*/
166+
public String authUid;
167+
143168

144169
public AppConfig(Map<String, Object> map) {
145170
List<String> urls = getList(map, "urls", null, new ArrayList<>());
@@ -163,6 +188,10 @@ public AppConfig(Map<String, Object> map) {
163188
long chunkSize = getLong(map, "chunk-size", null, 1024L * 1024);
164189
int maxChunks = getInt(map, "max-chunks", null, 16);
165190
boolean enableChunkedDownload = getBoolean(map, "enable-chunked-download", null, true);
191+
boolean antiHotlinkEnabled = getBoolean(map, "anti-hotlink-enabled", null, true);
192+
String authApiUrl = getString(map, "anti-hotlink-auth-url", null, "https://auth-api.mxzysoa.com/generate-auth-url");
193+
int authExpireTime = getInt(map, "anti-hotlink-expire-time", null, 3600);
194+
String authUid = getString(map, "anti-hotlink-uid", null, "0");
166195

167196
// if (urls.contains("webda"))
168197
//
@@ -188,6 +217,10 @@ public AppConfig(Map<String, Object> map) {
188217
this.chunkSize = chunkSize;
189218
this.maxChunks = maxChunks;
190219
this.enableChunkedDownload = enableChunkedDownload;
220+
this.antiHotlinkEnabled = antiHotlinkEnabled;
221+
this.authApiUrl = authApiUrl;
222+
this.authExpireTime = authExpireTime;
223+
this.authUid = authUid;
191224
}
192225

193226
@SuppressWarnings("unchecked")
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
package com.github.balloonupdate.mcpatch.client.network;
2+
3+
import com.github.balloonupdate.mcpatch.client.exceptions.McpatchBusinessException;
4+
import com.github.balloonupdate.mcpatch.client.logging.Log;
5+
import okhttp3.OkHttpClient;
6+
import okhttp3.Request;
7+
import okhttp3.Response;
8+
9+
import java.net.URI;
10+
import java.net.URLEncoder;
11+
import java.nio.charset.StandardCharsets;
12+
import java.util.Map;
13+
import java.util.concurrent.ConcurrentHashMap;
14+
import java.util.concurrent.TimeUnit;
15+
16+
/**
17+
* 阿里云ESA A方案鉴权凭据服务。
18+
* <p>
19+
* 在下载每个文件之前,向鉴权 API 请求 auth_key,然后拼接到下载 URL 中,
20+
* 实现防盗链功能,防止恶意反复下载。
21+
* <p>
22+
* 特性:
23+
* - 线程安全的凭据缓存,同一文件路径在有效期内复用 auth_key
24+
* - 提前刷新机制,在 auth_key 过期前 5 分钟主动刷新
25+
* - OkHttp 连接池复用,减少握手开销
26+
*/
27+
public class AuthKeyService {
28+
29+
/**
30+
* 鉴权 API 地址
31+
*/
32+
private final String authApiUrl;
33+
34+
/**
35+
* 鉴权 URL 有效时长,单位秒
36+
*/
37+
private final int expireTime;
38+
39+
/**
40+
* 鉴权用户ID
41+
*/
42+
private final String uid;
43+
44+
/**
45+
* OkHttp 客户端(复用连接池)
46+
*/
47+
private final OkHttpClient client;
48+
49+
/**
50+
* 自定义 HTTP headers,与配置文件中的 http-headers 保持一致
51+
*/
52+
private final Map<String, String> httpHeaders;
53+
54+
/**
55+
* 缓存:filePath → AuthKeyEntry
56+
* 使用 ConcurrentHashMap 保证多线程并行下载时的线程安全
57+
*/
58+
private final ConcurrentHashMap<String, AuthKeyEntry> cache = new ConcurrentHashMap<>();
59+
60+
/**
61+
* 提前刷新时间:5分钟(毫秒)
62+
* 在 auth_key 过期前 5 分钟主动刷新,避免下载过程中凭据过期
63+
*/
64+
private static final long REFRESH_AHEAD_MILLIS = 5 * 60 * 1000;
65+
66+
/**
67+
* 缓存条目,保存 auth_key 及其过期时间
68+
*/
69+
static class AuthKeyEntry {
70+
final String authKey;
71+
final long expireTimestampMillis;
72+
73+
AuthKeyEntry(String authKey, long expireTimestampMillis) {
74+
this.authKey = authKey;
75+
this.expireTimestampMillis = expireTimestampMillis;
76+
}
77+
}
78+
79+
/**
80+
* 创建鉴权凭据服务
81+
*
82+
* @param authApiUrl 鉴权 API 地址,例如 https://auth-api.mxzysoa.com/generate-auth-url
83+
* @param expireTime 鉴权 URL 有效时长,单位秒
84+
* @param uid 鉴权用户ID
85+
* @param httpTimeout HTTP 超时时间,单位毫秒
86+
* @param httpHeaders 自定义 HTTP headers,与配置文件中的 http-headers 保持一致
87+
*/
88+
public AuthKeyService(String authApiUrl, int expireTime, String uid, int httpTimeout, Map<String, String> httpHeaders) {
89+
this.authApiUrl = authApiUrl;
90+
this.expireTime = expireTime;
91+
this.uid = uid;
92+
this.httpHeaders = httpHeaders;
93+
94+
this.client = new OkHttpClient.Builder()
95+
.connectTimeout(httpTimeout, TimeUnit.MILLISECONDS)
96+
.readTimeout(httpTimeout, TimeUnit.MILLISECONDS)
97+
.writeTimeout(httpTimeout, TimeUnit.MILLISECONDS)
98+
.build();
99+
}
100+
101+
/**
102+
* 根据原始下载 URL 构建带鉴权参数的 URL。
103+
* <p>
104+
* 流程:
105+
* 1. 从 originalUrl 提取路径部分作为 filePath(例如 /index.json)
106+
* 2. 向鉴权 API 请求 auth_key(带缓存)
107+
* 3. 在 originalUrl 后追加 ?auth_key=xxx 或 &auth_key=xxx
108+
*
109+
* @param originalUrl 原始下载 URL
110+
* @return 带 auth_key 参数的 URL
111+
* @throws McpatchBusinessException 鉴权请求失败时抛出
112+
*/
113+
public String buildAuthUrl(String originalUrl) throws McpatchBusinessException {
114+
String filePath = extractFilePath(originalUrl);
115+
String authKey = getAuthKey(filePath);
116+
String separator = originalUrl.contains("?") ? "&" : "?";
117+
return originalUrl + separator + "auth_key=" + authKey;
118+
}
119+
120+
/**
121+
* 从完整 URL 中提取文件路径部分。
122+
* <p>
123+
* 例如:
124+
* - https://dl1.mxzysoa.com/index.json → /index.json
125+
* - https://dl1.mxzysoa.com/sub/v1.pack → /sub/v1.pack
126+
* - https://dl1.mxzysoa.com:8443/a/b/c.dat?q=1 → /a/b/c.dat
127+
*
128+
* @param url 完整的 URL
129+
* @return 以 / 开头的文件路径
130+
*/
131+
private String extractFilePath(String url) throws McpatchBusinessException {
132+
try {
133+
URI uri = new URI(url);
134+
String path = uri.getPath();
135+
if (path == null || path.isEmpty()) {
136+
path = "/";
137+
}
138+
return path;
139+
} catch (Exception e) {
140+
throw new McpatchBusinessException("无法从 URL 中提取文件路径: " + url, e);
141+
}
142+
}
143+
144+
/**
145+
* 获取 auth_key(带缓存,线程安全)。
146+
* <p>
147+
* 缓存命中且未过期时直接返回;缓存未命中或已过期时向鉴权 API 请求新的 auth_key。
148+
* 使用 ConcurrentHashMap 的原子操作保证同一路径在并发场景下不会重复请求。
149+
*
150+
* @param filePath 文件路径,以 / 开头
151+
* @return auth_key 字符串
152+
* @throws McpatchBusinessException 鉴权请求失败时抛出
153+
*/
154+
private String getAuthKey(String filePath) throws McpatchBusinessException {
155+
AuthKeyEntry entry = cache.get(filePath);
156+
157+
// 缓存命中且未过期
158+
if (entry != null && System.currentTimeMillis() < entry.expireTimestampMillis - REFRESH_AHEAD_MILLIS) {
159+
return entry.authKey;
160+
}
161+
162+
// 缓存未命中或已过期,请求新的 auth_key
163+
AuthKeyEntry newEntry = requestAuthKey(filePath);
164+
cache.put(filePath, newEntry);
165+
return newEntry.authKey;
166+
}
167+
168+
/**
169+
* 向鉴权 API 请求 auth_key。
170+
* <p>
171+
* 调用方式:GET {authApiUrl}?filePath={filePath}&expireTime={expireTime}&uid={uid}
172+
* 响应格式(rawKey=true,GET 默认):纯文本,格式为 {timestamp}-{rand}-{uid}-{md5hash}
173+
*
174+
* @param filePath 文件路径,以 / 开头
175+
* @return AuthKeyEntry 包含 auth_key 和过期时间
176+
* @throws McpatchBusinessException 请求失败时抛出
177+
*/
178+
private AuthKeyEntry requestAuthKey(String filePath) throws McpatchBusinessException {
179+
String encodedFilePath = URLEncoder.encode(filePath, StandardCharsets.UTF_8);
180+
181+
String url = authApiUrl
182+
+ "?filePath=" + encodedFilePath
183+
+ "&expireTime=" + expireTime
184+
+ "&uid=" + URLEncoder.encode(uid, StandardCharsets.UTF_8);
185+
186+
Log.info("正在获取下载凭据: " + filePath);
187+
188+
Request.Builder reqBuilder = new Request.Builder()
189+
.url(url)
190+
.get();
191+
192+
// 使用 header()(替换模式)而非 addHeader()(追加模式),
193+
// 确保配置文件中的 User-Agent 等自定义 headers 是唯一的、权威的值,
194+
// 不会与 OkHttp 或系统默认值产生重复
195+
if (httpHeaders != null) {
196+
for (Map.Entry<String, String> e : httpHeaders.entrySet()) {
197+
reqBuilder.header(e.getKey(), e.getValue());
198+
}
199+
}
200+
201+
Request request = reqBuilder.build();
202+
203+
try (Response response = client.newCall(request).execute()) {
204+
if (!response.isSuccessful()) {
205+
String body = response.peekBody(300).string();
206+
throw new McpatchBusinessException(
207+
String.format("鉴权API返回错误 %d: %s (filePath=%s)\n%s",
208+
response.code(), response.message(), filePath, body));
209+
}
210+
211+
String authKey = response.body().string().trim();
212+
213+
if (authKey.isEmpty()) {
214+
throw new McpatchBusinessException("鉴权API返回了空的auth_key (filePath=" + filePath + ")");
215+
}
216+
217+
// 解析 auth_key 中的时间戳,计算过期时间
218+
// auth_key 格式:{timestamp}-{rand}-{uid}-{md5hash}
219+
long expireTimestampMillis = parseExpireTimestamp(authKey);
220+
221+
Log.info("下载凭据获取成功: " + filePath);
222+
223+
return new AuthKeyEntry(authKey, expireTimestampMillis);
224+
} catch (McpatchBusinessException e) {
225+
Log.warn("获取下载凭据失败: " + filePath);
226+
throw e;
227+
} catch (Exception e) {
228+
Log.warn("获取下载凭据失败: " + filePath);
229+
throw new McpatchBusinessException("请求鉴权凭据失败: filePath=" + filePath, e);
230+
}
231+
}
232+
233+
/**
234+
* 从 auth_key 字符串中解析过期时间戳。
235+
* <p>
236+
* auth_key 格式:{timestamp}-{rand}-{uid}-{md5hash}
237+
* timestamp 是十进制 Unix 时间戳(秒),表示 auth_key 失效时间。
238+
*
239+
* @param authKey auth_key 字符串
240+
* @return 过期时间戳(毫秒)
241+
*/
242+
private long parseExpireTimestamp(String authKey) {
243+
try {
244+
int dashIndex = authKey.indexOf('-');
245+
if (dashIndex > 0) {
246+
long timestampSeconds = Long.parseLong(authKey.substring(0, dashIndex));
247+
return timestampSeconds * 1000;
248+
}
249+
} catch (NumberFormatException e) {
250+
Log.warn("无法解析auth_key中的时间戳,使用默认过期时间");
251+
}
252+
253+
// 解析失败时,使用当前时间 + expireTime 作为过期时间
254+
return System.currentTimeMillis() + (long) expireTime * 1000;
255+
}
256+
257+
/**
258+
* 清除所有缓存。
259+
* 通常在关闭时调用。
260+
*/
261+
public void clearCache() {
262+
cache.clear();
263+
}
264+
265+
/**
266+
* 关闭鉴权服务,释放资源。
267+
* 关闭 OkHttp 连接池和调度器,清除缓存。
268+
*/
269+
public void shutdown() {
270+
cache.clear();
271+
client.dispatcher().executorService().shutdown();
272+
client.connectionPool().evictAll();
273+
}
274+
275+
/**
276+
* 使指定文件路径的缓存失效。
277+
* <p>
278+
* 当下载请求因鉴权失败(如 CDN 返回 403)时调用此方法,
279+
* 强制下次请求时重新获取 auth_key,避免用过期的凭据反复重试。
280+
*
281+
* @param filePath 文件路径,以 / 开头
282+
*/
283+
public void invalidateCache(String filePath) {
284+
cache.remove(filePath);
285+
Log.info("下载凭据已失效,将重新获取: " + filePath);
286+
}
287+
288+
/**
289+
* 从完整 URL 中提取文件路径并使对应的缓存失效。
290+
* <p>
291+
* 便捷方法,等价于 {@code invalidateCache(extractFilePath(url))}
292+
*
293+
* @param url 完整的下载 URL
294+
*/
295+
public void invalidateCacheByUrl(String url) throws McpatchBusinessException {
296+
invalidateCache(extractFilePath(url));
297+
}
298+
}

0 commit comments

Comments
 (0)