Compare commits

...
12 changed files with 195 additions and 116 deletions

No files matched your search

@@ -27,15 +27,16 @@ import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.util.DigestUtils; import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException; import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jackhuang.hmcl.util.logging.Logger; import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.util.tree.TarFileTree; import org.jackhuang.hmcl.util.tree.TarFileTree;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.URI; import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.DigestInputStream; import java.security.DigestInputStream;
@@ -66,7 +67,7 @@ public final class TerracottaBundle {
.thenComposeAsync(Schedulers.javafx(), pkg -> { .thenComposeAsync(Schedulers.javafx(), pkg -> {
FileDownloadTask download = new FileDownloadTask(links, pkg, hash) { FileDownloadTask download = new FileDownloadTask(links, pkg, hash) {
@Override @Override
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException { protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException {
FetchTask.Context delegate = super.getContext(response, checkETag, bmclapiHash); FetchTask.Context delegate = super.getContext(response, checkETag, bmclapiHash);
return new Context() { return new Context() {
@Override @Override
@@ -35,7 +35,6 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.http.HttpResponse;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.*; import java.util.*;
@@ -245,7 +244,7 @@ public class Skin {
} }
@Override @Override
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException { protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException {
return new Context() { return new Context() {
final ByteArrayOutputStream baos = new ByteArrayOutputStream(); final ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -266,7 +265,7 @@ public class Skin {
setResult(new ByteArrayInputStream(baos.toByteArray())); setResult(new ByteArrayInputStream(baos.toByteArray()));
if (checkETag) { if (checkETag) {
repository.cacheBytes(UrlResponseInfo.of(response), baos.toByteArray()); repository.cacheBytes(response, baos.toByteArray());
} }
} }
}; };
@@ -47,20 +47,21 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class CurseForgeRemoteModRepository implements RemoteModRepository { public final class CurseForgeRemoteModRepository implements RemoteModRepository {
private static final String PREFIX = "https://api.curseforge.com"; private static final String PREFIX = "https://api.curseforge.com";
private static final String apiKey = System.getProperty("hmcl.curseforge.apikey", JarUtils.getAttribute("hmcl.curseforge.apikey", ""));
private static final Semaphore SEMAPHORE = new Semaphore(16); private static final Semaphore SEMAPHORE = new Semaphore(16);
public static final String API_KEY = System.getProperty("hmcl.curseforge.apikey", JarUtils.getAttribute("hmcl.curseforge.apikey", ""));
private static final int WORD_PERFECT_MATCH_WEIGHT = 5; private static final int WORD_PERFECT_MATCH_WEIGHT = 5;
private static <R extends HttpRequest> R withApiKey(R request) { private static <R extends HttpRequest> R withApiKey(R request) {
if (request.getUrl().startsWith(PREFIX) && !apiKey.isEmpty()) { if (request.getUrl().startsWith(PREFIX) && !API_KEY.isEmpty()) {
request.header("X-API-KEY", apiKey); request.header("X-API-KEY", API_KEY);
} }
return request; return request;
} }
public static boolean isAvailable() { public static boolean isAvailable() {
return !apiKey.isEmpty(); return !API_KEY.isEmpty();
} }
private final Type type; private final Type type;
@@ -25,7 +25,6 @@ import org.jetbrains.annotations.Nullable;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.channels.FileChannel; import java.nio.channels.FileChannel;
import java.nio.file.Files; import java.nio.file.Files;
@@ -84,7 +83,7 @@ public final class CacheFileTask extends FetchTask<Path> {
} }
@Override @Override
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException { protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException {
assert checkETag; assert checkETag;
assert response != null; assert response != null;
@@ -125,7 +124,7 @@ public final class CacheFileTask extends FetchTask<Path> {
} }
try { try {
setResult(repository.cacheRemoteFile(UrlResponseInfo.of(response), temp)); setResult(repository.cacheRemoteFile(response, temp));
} finally { } finally {
deleteTempFile(); deleteTempFile();
} }
@@ -29,9 +29,6 @@ import java.io.*;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URI; import java.net.URI;
import java.net.URLConnection; import java.net.URLConnection;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.*; import java.util.*;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@@ -47,7 +44,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public abstract class FetchTask<T> extends Task<T> { public abstract class FetchTask<T> extends Task<T> {
protected static final int DEFAULT_RETRY = 3; protected static final int DEFAULT_RETRY = 5;
protected final List<URI> uris; protected final List<URI> uris;
protected int retry = DEFAULT_RETRY; protected int retry = DEFAULT_RETRY;
@@ -86,7 +83,7 @@ public abstract class FetchTask<T> extends Task<T> {
return getContext(null, false, null); return getContext(null, false, null);
} }
protected abstract Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException; protected abstract Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException;
@Override @Override
public void execute() throws Exception { public void execute() throws Exception {
@@ -136,8 +133,8 @@ public abstract class FetchTask<T> extends Task<T> {
private static final class HttpResumeContext { private static final class HttpResumeContext {
private static final Pattern CONTENT_RANGE_PATTERN = Pattern.compile("bytes ([0-9]+)-([0-9]+)/([0-9]+)"); private static final Pattern CONTENT_RANGE_PATTERN = Pattern.compile("bytes ([0-9]+)-([0-9]+)/([0-9]+)");
static @Nullable FetchTask.HttpResumeContext of(HttpResponse<?> response) throws IOException { static @Nullable FetchTask.HttpResumeContext of(int statusCode, UrlResponseInfo response) throws IOException {
if (response.statusCode() != HttpURLConnection.HTTP_OK) if (statusCode != HttpURLConnection.HTTP_OK)
return null; return null;
boolean acceptRanges = response.headers().firstValue("accept-ranges").orElse("").equalsIgnoreCase("bytes"); boolean acceptRanges = response.headers().firstValue("accept-ranges").orElse("").equalsIgnoreCase("bytes");
@@ -183,8 +180,8 @@ public abstract class FetchTask<T> extends Task<T> {
return strongETag != null ? strongETag : Objects.requireNonNull(lastModified); return strongETag != null ? strongETag : Objects.requireNonNull(lastModified);
} }
boolean canResume(HttpResponse<?> response) throws IOException { boolean canResume(int statusCode, UrlResponseInfo response) throws IOException {
if (response.statusCode() != HttpURLConnection.HTTP_PARTIAL) if (statusCode != HttpURLConnection.HTTP_PARTIAL)
return false; return false;
var contentEncoding = ContentEncoding.fromHeaders(response.headers()); var contentEncoding = ContentEncoding.fromHeaders(response.headers());
@@ -312,8 +309,10 @@ public abstract class FetchTask<T> extends Task<T> {
beforeDownload(uri); beforeDownload(uri);
updateProgress(0); updateProgress(0);
HttpResponse<InputStream> response; HttpURLConnection connection = null;
UrlResponseInfo responseInfo;
String bmclapiHash; String bmclapiHash;
int responseCode;
URI currentURI = uri; URI currentURI = uri;
@@ -329,51 +328,56 @@ public abstract class FetchTask<T> extends Task<T> {
} }
do { do {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI) connection = NetworkUtils.createHttpConnection(currentURI);
.timeout(NetworkUtils.TIMEOUT) boolean keepConnection = false;
.header("User-Agent", NetworkUtils.USER_AGENT); try {
headers.forEach(requestBuilder::header); headers.forEach(connection::setRequestProperty);
response = Holder.HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER); responseCode = connection.getResponseCode();
responseInfo = UrlResponseInfo.of(connection);
bmclapiHash = response.headers().firstValue("x-bmclapi-hash").orElse(null); bmclapiHash = responseInfo.headers().firstValue("x-bmclapi-hash").orElse(null);
if (DigestUtils.isSha1Digest(bmclapiHash)) { if (DigestUtils.isSha1Digest(bmclapiHash)) {
Optional<Path> cache = repository.checkExistentFile(null, "SHA-1", bmclapiHash); Optional<Path> cache = repository.checkExistentFile(null, "SHA-1", bmclapiHash);
if (cache.isPresent()) { if (cache.isPresent()) {
closeResponseBody(response); useCachedResult(cache.get());
useCachedResult(cache.get()); LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri)); return;
return; }
}
}
int code = response.statusCode();
if (code >= 300 && code <= 308 && code != 306 && code != 304) {
if (redirects == null) {
redirects = new ArrayList<>();
} else if (redirects.size() >= 20) {
throw new IOException("Too much redirects");
} }
String location = response.headers().firstValue("Location").orElse(null); if (responseCode >= 300 && responseCode <= 308 && responseCode != 306 && responseCode != 304) {
if (StringUtils.isBlank(location)) if (redirects == null) {
throw new IOException("Redirected to an empty location"); redirects = new ArrayList<>();
} else if (redirects.size() >= 20) {
throw new IOException("Too much redirects");
}
URI target = currentURI.resolve(NetworkUtils.encodeLocation(location)); String location = connection.getHeaderField("Location");
redirects.add(target); if (StringUtils.isBlank(location))
throw new IOException("Redirected to an empty location");
if (!NetworkUtils.isHttpUri(target)) URI target = currentURI.resolve(NetworkUtils.encodeLocation(location));
throw new IOException("Redirected to not http URI: " + target); redirects.add(target);
closeResponseBody(response); if (!NetworkUtils.isHttpUri(target))
currentURI = target; throw new IOException("Redirected to not http URI: " + target);
} else {
break; currentURI = target;
} else {
keepConnection = true;
break;
}
} finally {
if (!keepConnection && connection != null) {
closeHttpConnection(connection);
connection = null;
}
} }
} while (true); } while (true);
boolean closeResponseBody = true; InputStream inputStream = null;
boolean responseBodyConsumed = false;
try { try {
int responseCode = response.statusCode();
if (resumeRequested && responseCode == 416) { if (resumeRequested && responseCode == 416) {
resumeContext = null; resumeContext = null;
discardContext(context); discardContext(context);
@@ -385,7 +389,7 @@ public abstract class FetchTask<T> extends Task<T> {
if (useCachedResult && responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) { if (useCachedResult && responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
// Handle cache // Handle cache
try { try {
Path cache = repository.getCachedRemoteFile(currentURI, false); Path cache = repository.getCachedRemoteFile(responseInfo.uri(), false);
useCachedResult(cache); useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri)); LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return; return;
@@ -406,14 +410,14 @@ public abstract class FetchTask<T> extends Task<T> {
throw new ResponseCodeException(uri, responseCode); throw new ResponseCodeException(uri, responseCode);
} }
long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1L); long contentLength = responseInfo.headers().firstValueAsLong("content-length").orElse(-1L);
var contentEncoding = ContentEncoding.fromHeaders(response.headers()); var contentEncoding = ContentEncoding.fromHeaders(responseInfo.headers());
if (context == null) { if (context == null) {
context = getContext(response, checkETag, bmclapiHash); context = getContext(responseInfo, checkETag, bmclapiHash);
resumeContext = HttpResumeContext.of(response); resumeContext = HttpResumeContext.of(responseCode, responseInfo);
} else if (resumeRequested) { } else if (resumeRequested) {
if (resumeContext.canResume(response)) { if (resumeContext.canResume(responseCode, responseInfo)) {
// Resume download // Resume download
LOG.info("Resuming " + resumeContext.uri + " from " + resumeContext.countUncompressed); LOG.info("Resuming " + resumeContext.uri + " from " + resumeContext.countUncompressed);
} else { } else {
@@ -426,16 +430,18 @@ public abstract class FetchTask<T> extends Task<T> {
} }
} else { } else {
discardContext(context); discardContext(context);
context = getContext(response, checkETag, bmclapiHash); context = getContext(responseInfo, checkETag, bmclapiHash);
resumeContext = HttpResumeContext.of(response); resumeContext = HttpResumeContext.of(responseCode, responseInfo);
} }
try { try {
closeResponseBody = false; inputStream = connection.getInputStream();
download(context, download(context,
resumeContext, response.body(), resumeContext, inputStream,
contentLength, contentLength,
contentEncoding); contentEncoding);
inputStream = null;
responseBodyConsumed = true;
} catch (IOException | InterruptedException | RuntimeException | Error e) { } catch (IOException | InterruptedException | RuntimeException | Error e) {
if (context.broken) { if (context.broken) {
IOUtils.closeQuietly(context, e); IOUtils.closeQuietly(context, e);
@@ -456,9 +462,9 @@ public abstract class FetchTask<T> extends Task<T> {
context = null; context = null;
return; return;
} finally { } finally {
if (closeResponseBody) { IOUtils.closeQuietly(inputStream);
closeResponseBody(response); if (connection != null && !responseBodyConsumed)
} closeHttpConnection(connection);
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
throw e; throw e;
@@ -492,10 +498,10 @@ public abstract class FetchTask<T> extends Task<T> {
throw toDownloadException(uri, null, exceptions); throw toDownloadException(uri, null, exceptions);
} }
private static void closeResponseBody(HttpResponse<InputStream> response) { /// Disconnects an HTTP URL connection whose response body will not be consumed.
InputStream body = response.body(); private static void closeHttpConnection(HttpURLConnection connection) {
if (body != null) IOUtils.closeQuietly(connection.getErrorStream());
IOUtils.closeQuietly(body); connection.disconnect();
} }
private static void discardContext(@Nullable Context context) { private static void discardContext(@Nullable Context context) {
@@ -649,12 +655,6 @@ public abstract class FetchTask<T> extends Task<T> {
CACHED CACHED
} }
private static final HttpResponse.BodyHandler<InputStream> BODY_HANDLER = responseInfo -> {
if (responseInfo.statusCode() / 100 == 2)
return HttpResponse.BodySubscribers.ofInputStream();
else
return HttpResponse.BodySubscribers.replacing(null);
};
public static int DEFAULT_CONCURRENCY = Math.min(Runtime.getRuntime().availableProcessors() * 4, 64); public static int DEFAULT_CONCURRENCY = Math.min(Runtime.getRuntime().availableProcessors() * 4, 64);
private static int downloadExecutorConcurrency = DEFAULT_CONCURRENCY; private static int downloadExecutorConcurrency = DEFAULT_CONCURRENCY;
@@ -726,24 +726,4 @@ public abstract class FetchTask<T> extends Task<T> {
initialized = true; initialized = true;
} }
/// Ensure that [#HTTP_CLIENT] is initialized after ProxyManager has been initialized.
private static final class Holder {
private static final HttpClient HTTP_CLIENT;
static {
if (!initialized) {
throw new AssertionError("FetchTask.Holder accessed before ProxyManager initialization.");
}
boolean useHttp2 = !"false".equalsIgnoreCase(System.getProperty("hmcl.http2"));
HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(NetworkUtils.TIMEOUT)
.version(useHttp2 ? HttpClient.Version.HTTP_2 : HttpClient.Version.HTTP_1_1)
.build();
}
private Holder() {
}
}
} }
@@ -28,7 +28,6 @@ import org.jetbrains.annotations.Nullable;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.channels.FileChannel; import java.nio.channels.FileChannel;
import java.nio.file.FileSystem; import java.nio.file.FileSystem;
@@ -179,7 +178,7 @@ public class FileDownloadTask extends FetchTask<Void> {
} }
@Override @Override
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException { protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException {
Path temp = Files.createTempFile(null, null); Path temp = Files.createTempFile(null, null);
String algorithm; String algorithm;
@@ -271,7 +270,7 @@ public class FileDownloadTask extends FetchTask<Void> {
} }
if (checkETag) { if (checkETag) {
repository.cacheRemoteFile(UrlResponseInfo.of(response), file); repository.cacheRemoteFile(response, file);
} }
} finally { } finally {
if (!moved) { if (!moved) {
@@ -26,7 +26,6 @@ import org.jetbrains.annotations.Nullable;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
@@ -63,7 +62,7 @@ public final class GetTask extends FetchTask<String> {
} }
@Override @Override
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) { protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) {
long length = -1; long length = -1;
if (response != null) if (response != null)
length = response.headers().firstValueAsLong("content-length").orElse(-1L); length = response.headers().firstValueAsLong("content-length").orElse(-1L);
@@ -92,7 +91,7 @@ public final class GetTask extends FetchTask<String> {
setResult(result); setResult(result);
if (checkETag) { if (checkETag) {
repository.cacheText(UrlResponseInfo.of(response), result); repository.cacheText(response, result);
} }
} }
}; };
@@ -41,7 +41,7 @@ public final class IOUtils {
private IOUtils() { private IOUtils() {
} }
public static final int DEFAULT_BUFFER_SIZE = 8 * 1024; public static final int DEFAULT_BUFFER_SIZE = 32 * 1024;
public static BufferedReader newBufferedReaderMaybeNativeEncoding(Path file) throws IOException { public static BufferedReader newBufferedReaderMaybeNativeEncoding(Path file) throws IOException {
if (NATIVE_CHARSET == UTF_8) if (NATIVE_CHARSET == UTF_8)
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.util.io; package org.jackhuang.hmcl.util.io;
import org.glavo.url.WebURL; import org.glavo.url.WebURL;
import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository;
import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@@ -47,7 +48,7 @@ public final class NetworkUtils {
public static final String PARAMETER_SEPARATOR = "&"; public static final String PARAMETER_SEPARATOR = "&";
public static final String NAME_VALUE_SEPARATOR = "="; public static final String NAME_VALUE_SEPARATOR = "=";
public static final Duration TIMEOUT = Duration.ofSeconds(30); public static final Duration TIMEOUT = Duration.ofSeconds(10);
public static final int TIMEOUT_MILLIS = (int) TIMEOUT.toMillis(); public static final int TIMEOUT_MILLIS = (int) TIMEOUT.toMillis();
private NetworkUtils() { private NetworkUtils() {
@@ -163,6 +164,45 @@ public final class NetworkUtils {
} }
} }
private static final List<Pair<String, String>> API_KEYS;
static {
if (CurseForgeRemoteModRepository.API_KEY.isEmpty()) {
API_KEYS = List.of();
} else {
API_KEYS = List.of(
pair("api.curseforge.com", CurseForgeRemoteModRepository.API_KEY),
pair("forgecdn.net", CurseForgeRemoteModRepository.API_KEY)
);
}
}
private static boolean matchDomainSuffix(String domain, String suffix) {
return domain.endsWith(suffix)
&& (domain.length() == suffix.length() || domain.charAt(domain.length() - suffix.length() - 1) == '.');
}
public static void injectApiKey(WebURL url, URLConnection connection) {
if (!(connection instanceof HttpURLConnection))
return;
if (connection.getRequestProperty("x-api-key") != null) {
return;
}
String host = url.getHost();
if (host == null || host.isEmpty())
return;
for (Pair<String, String> pair : API_KEYS) {
String hostSuffix = pair.getKey();
if (matchDomainSuffix(host, hostSuffix)) {
connection.addRequestProperty("x-api-key", pair.getValue());
return;
}
}
}
public static URLConnection createConnection(WebURL url) throws IOException { public static URLConnection createConnection(WebURL url) throws IOException {
URLConnection connection; URLConnection connection;
try { try {
@@ -176,6 +216,7 @@ public final class NetworkUtils {
httpConnection.setRequestProperty("Accept-Language", Locale.getDefault().toLanguageTag()); httpConnection.setRequestProperty("Accept-Language", Locale.getDefault().toLanguageTag());
httpConnection.setRequestProperty("User-Agent", USER_AGENT); httpConnection.setRequestProperty("User-Agent", USER_AGENT);
httpConnection.setInstanceFollowRedirects(false); httpConnection.setInstanceFollowRedirects(false);
injectApiKey(url, connection);
} }
return connection; return connection;
} }
@@ -293,10 +334,10 @@ public final class NetworkUtils {
throw new IOException("Too much redirects"); throw new IOException("Too much redirects");
} }
HttpURLConnection redirected = (HttpURLConnection) new URL(conn.getURL(), encodeLocation(newURL)) WebURL redirectedUrl = WebURL.of(conn.getURL()).resolve(newURL);
.openConnection(); HttpURLConnection redirected = (HttpURLConnection) redirectedUrl.toURL().openConnection();
properties properties.forEach((key, value) -> value.forEach(element -> redirected.addRequestProperty(key, element)));
.forEach((key, value) -> value.forEach(element -> redirected.addRequestProperty(key, element))); injectApiKey(redirectedUrl, redirected);
redirected.setRequestMethod(method); redirected.setRequestMethod(method);
conn = redirected; conn = redirected;
++redirect; ++redirect;
@@ -17,17 +17,60 @@
*/ */
package org.jackhuang.hmcl.util.io; package org.jackhuang.hmcl.util.io;
import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.net.http.HttpHeaders; import java.net.http.HttpHeaders;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/// Response URI and headers captured from a URL response.
///
/// @author Glavo /// @author Glavo
public record UrlResponseInfo(URI uri, HttpHeaders headers) { public record UrlResponseInfo(URI uri, HttpHeaders headers) {
/// Creates response metadata from a URL connection.
public static UrlResponseInfo of(URLConnection connection) throws IOException {
return new UrlResponseInfo(toURI(connection.getURL()), HttpHeaders.of(headers(connection), (name, value) -> true));
}
/// Creates response metadata from an HTTP client response.
public static UrlResponseInfo of(HttpResponse<?> httpResponse) { public static UrlResponseInfo of(HttpResponse<?> httpResponse) {
return new UrlResponseInfo(httpResponse.uri(), httpResponse.headers()); return new UrlResponseInfo(httpResponse.uri(), httpResponse.headers());
} }
/// Creates response metadata from an HTTP client response-info object.
public static UrlResponseInfo of(URI uri, HttpResponse.ResponseInfo info) { public static UrlResponseInfo of(URI uri, HttpResponse.ResponseInfo info) {
return new UrlResponseInfo(uri, info.headers()); return new UrlResponseInfo(uri, info.headers());
} }
/// Converts a response URL into a URI.
private static URI toURI(URL url) throws IOException {
try {
return url.toURI();
} catch (URISyntaxException e) {
throw new IOException("Invalid response URL: " + url, e);
}
}
/// Copies named header fields from a URL connection.
private static Map<String, List<String>> headers(URLConnection connection) {
Map<String, List<String>> headerFields = connection.getHeaderFields();
if (headerFields == null || headerFields.isEmpty()) {
return Map.of();
}
LinkedHashMap<String, List<String>> headers = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> entry : headerFields.entrySet()) {
String name = entry.getKey();
List<String> values = entry.getValue();
if (name != null && values != null) {
headers.put(name, List.copyOf(values));
}
}
return headers;
}
} }
@@ -914,6 +914,23 @@
26.1 26.1
26.1.1-rc-1 26.1.1-rc-1
26.1.1 26.1.1
26w14a
26.1.2-rc-1 26.1.2-rc-1
26.1.2 26.1.2
26w14a 26.2-snapshot-1
26.2-snapshot-2
26.2-snapshot-3
26.2-snapshot-4
26.2-snapshot-5
26.2-snapshot-6
26.2-snapshot-7
26.2-snapshot-8
26.2-pre-1
26.2-pre-2
26.2-pre-3
26.2-pre-4
26.2-pre-5
26.2-pre-6
26.2-rc-1
26.2-rc-2
26.2
@@ -21,6 +21,7 @@ import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import org.jackhuang.hmcl.util.CacheRepository; import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -30,7 +31,6 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.net.URI; import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -45,7 +45,7 @@ import static org.junit.jupiter.api.Assertions.*;
/// Tests HTTP download retry and resume behavior. /// Tests HTTP download retry and resume behavior.
@NotNullByDefault @NotNullByDefault
public final class FetchTaskTest { public final class FetchTaskTest {
/// Marks HTTP client dependencies as initialized for isolated unit tests. /// Marks HTTP connection dependencies as initialized for isolated unit tests.
@org.junit.jupiter.api.BeforeAll @org.junit.jupiter.api.BeforeAll
public static void notifyFetchTaskInitialized() { public static void notifyFetchTaskInitialized() {
FetchTask.notifyInitialized(); FetchTask.notifyInitialized();
@@ -257,7 +257,7 @@ public final class FetchTaskTest {
} }
@Override @Override
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) { protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) {
Charset charset = NetworkUtils.getCharsetFromContentType(response == null Charset charset = NetworkUtils.getCharsetFromContentType(response == null
? null ? null
: response.headers().firstValue("content-type").orElse(null)); : response.headers().firstValue("content-type").orElse(null));