统一转义 URI 中的特殊字符 (#4181)

This commit is contained in:
Glavo
2025-08-03 19:34:34 +08:00
committed by GitHub
parent e2df8e25b1
commit 74c647cc97
44 changed files with 259 additions and 224 deletions

View File

@@ -29,6 +29,7 @@ import java.net.URISyntaxException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.util.io.HttpRequest;
@@ -58,10 +59,10 @@ public class AuthlibInjectorServer implements Observable {
public static AuthlibInjectorServer locateServer(String url) throws IOException {
try {
url = addHttpsIfMissing(url);
HttpURLConnection conn = NetworkUtils.createHttpConnection(URI.create(url));
HttpURLConnection conn = NetworkUtils.createHttpConnection(url);
String ali = conn.getHeaderField("x-authlib-injector-api-location");
if (ali != null) {
URI absoluteAli = conn.getURL().toURI().resolve(ali);
URI absoluteAli = conn.getURL().toURI().resolve(NetworkUtils.toURI(ali));
if (!urlEqualsIgnoreSlash(url, absoluteAli.toString())) {
conn.disconnect();
url = absoluteAli.toString();
@@ -85,15 +86,15 @@ public class AuthlibInjectorServer implements Observable {
}
private static String addHttpsIfMissing(String url) throws IOException {
URI uri = URI.create(url);
if (uri.getScheme() == null) {
return "https://" + url;
} else if (!NetworkUtils.isHttpUri(uri)) {
throw new IOException("Yggdrasil server should be an HTTP or HTTPS URI, but got: " + url);
} else {
if (Pattern.compile("^(?<scheme>[a-zA-Z][a-zA-Z0-9+.-]*)://").matcher(url).find())
return url;
}
if (url.startsWith("//"))
return "https:" + url;
else if (url.startsWith("/"))
return "https:/" + url;
else
return "https://" + url;
}
private static boolean urlEqualsIgnoreSlash(String a, String b) {

View File

@@ -34,7 +34,6 @@ import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -165,7 +164,7 @@ public class MicrosoftService {
.accept("application/json").createConnection();
if (request.getResponseCode() != 200) {
throw new ResponseCodeException(URI.create("https://api.minecraftservices.com/entitlements/mcstore"), request.getResponseCode());
throw new ResponseCodeException("https://api.minecraftservices.com/entitlements/mcstore", request.getResponseCode());
}
// Get Minecraft Account UUID
@@ -248,7 +247,7 @@ public class MicrosoftService {
if (responseCode == HTTP_NOT_FOUND) {
throw new NoMinecraftJavaEditionProfileException();
} else if (responseCode != 200) {
throw new ResponseCodeException(URI.create("https://api.minecraftservices.com/minecraft/profile"), responseCode);
throw new ResponseCodeException("https://api.minecraftservices.com/minecraft/profile", responseCode);
}
String result = NetworkUtils.readFullyAsString(conn);
@@ -258,12 +257,12 @@ public class MicrosoftService {
public Optional<CompleteGameProfile> getCompleteGameProfile(UUID uuid) throws AuthenticationException {
Objects.requireNonNull(uuid);
return Optional.ofNullable(GSON.fromJson(request(URI.create("https://sessionserver.mojang.com/session/minecraft/profile/" + UUIDTypeAdapter.fromUUID(uuid)), null), CompleteGameProfile.class));
return Optional.ofNullable(GSON.fromJson(request("https://sessionserver.mojang.com/session/minecraft/profile/" + UUIDTypeAdapter.fromUUID(uuid), null), CompleteGameProfile.class));
}
public void uploadSkin(String accessToken, boolean isSlim, Path file) throws AuthenticationException, UnsupportedOperationException {
try {
HttpURLConnection con = NetworkUtils.createHttpConnection(URI.create("https://api.minecraftservices.com/minecraft/profile/skins"));
HttpURLConnection con = NetworkUtils.createHttpConnection("https://api.minecraftservices.com/minecraft/profile/skins");
con.setRequestMethod("POST");
con.setRequestProperty("Authorization", "Bearer " + accessToken);
con.setDoOutput(true);
@@ -288,12 +287,12 @@ public class MicrosoftService {
}
}
private static String request(URI url, Object payload) throws AuthenticationException {
private static String request(String url, Object payload) throws AuthenticationException {
try {
if (payload == null)
return NetworkUtils.doGet(url);
else
return NetworkUtils.doPost(url, payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json");
return NetworkUtils.doPost(NetworkUtils.toURI(url), payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json");
} catch (IOException e) {
throw new ServerDisconnectException(e);
}

View File

@@ -27,13 +27,13 @@ import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.Nullable;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLConnection;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -166,7 +166,7 @@ public class Skin {
String realCslApi = type == Type.LITTLE_SKIN
? "https://littleskin.cn/csl"
: StringUtils.removeSuffix(Lang.requireNonNullElse(cslApi, ""), "/");
return Task.composeAsync(() -> new GetTask(URI.create(String.format("%s/%s.json", realCslApi, username))))
return Task.composeAsync(() -> new GetTask(String.format("%s/%s.json", realCslApi, username)))
.thenComposeAsync(json -> {
SkinJson result = JsonUtils.GSON.fromJson(json, SkinJson.class);
@@ -176,8 +176,8 @@ public class Skin {
return Task.allOf(
Task.supplyAsync(result::getModel),
result.getHash() == null ? Task.supplyAsync(() -> null) : new FetchBytesTask(URI.create(String.format("%s/textures/%s", realCslApi, result.getHash())), 3),
result.getCapeHash() == null ? Task.supplyAsync(() -> null) : new FetchBytesTask(URI.create(String.format("%s/textures/%s", realCslApi, result.getCapeHash())), 3)
result.getHash() == null ? Task.supplyAsync(() -> null) : new FetchBytesTask(String.format("%s/textures/%s", realCslApi, result.getHash())),
result.getCapeHash() == null ? Task.supplyAsync(() -> null) : new FetchBytesTask(String.format("%s/textures/%s", realCslApi, result.getCapeHash()))
);
}).thenApplyAsync(result -> {
if (result == null) {
@@ -229,8 +229,8 @@ public class Skin {
private static class FetchBytesTask extends FetchTask<InputStream> {
public FetchBytesTask(URI uri, int retry) {
super(List.of(uri), retry);
public FetchBytesTask(String uri) {
super(List.of(NetworkUtils.toURI(uri)));
}
@Override

View File

@@ -17,6 +17,8 @@
*/
package org.jackhuang.hmcl.download;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.net.URI;
import java.util.List;
import java.util.stream.Collectors;
@@ -33,7 +35,7 @@ public interface DownloadProvider {
String getAssetBaseURL();
default List<URI> getAssetObjectCandidates(String assetObjectLocation) {
return List.of(URI.create(getAssetBaseURL() + assetObjectLocation));
return List.of(NetworkUtils.toURI(getAssetBaseURL() + assetObjectLocation));
}
/**
@@ -57,7 +59,7 @@ public interface DownloadProvider {
* @return the URL that is equivalent to [baseURL], but belongs to your own service provider.
*/
default List<URI> injectURLWithCandidates(String baseURL) {
return List.of(URI.create(injectURL(baseURL)));
return List.of(NetworkUtils.toURI(injectURL(baseURL)));
}
default List<URI> injectURLsWithCandidates(List<String> urls) {

View File

@@ -23,7 +23,6 @@ import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
@@ -59,7 +58,7 @@ public final class FabricAPIInstallTask extends Task<Version> {
@Override
public void execute() throws IOException {
dependencies.add(new FileDownloadTask(
URI.create(remote.getVersion().getFile().getUrl()),
remote.getVersion().getFile().getUrl(),
dependencyManager.getGameRepository().getRunDirectory(version.getId()).toPath().resolve("mods").resolve("fabric-api-" + remote.getVersion().getVersion() + ".jar"),
remote.getVersion().getFile().getIntegrityCheck())
);

View File

@@ -29,7 +29,6 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.URI;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
@@ -86,7 +85,7 @@ public final class ForgeBMCLVersionList extends VersionList<ForgeRemoteVersion>
public Task<?> refreshAsync(String gameVersion) {
String lookupVersion = toLookupVersion(gameVersion);
return new GetTask(URI.create(apiRoot + "/forge/minecraft/" + lookupVersion)).thenGetJsonAsync(listTypeOf(ForgeVersion.class))
return new GetTask(apiRoot + "/forge/minecraft/" + lookupVersion).thenGetJsonAsync(listTypeOf(ForgeVersion.class))
.thenAcceptAsync(forgeVersions -> {
lock.writeLock().lock();
try {

View File

@@ -25,7 +25,6 @@ import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.URI;
import java.util.Collection;
import java.util.Collections;
@@ -54,7 +53,7 @@ public final class GameVersionList extends VersionList<GameRemoteVersion> {
@Override
public Task<?> refreshAsync() {
return new GetTask(URI.create(downloadProvider.getVersionListURL())).thenGetJsonAsync(GameRemoteVersions.class)
return new GetTask(downloadProvider.getVersionListURL()).thenGetJsonAsync(GameRemoteVersions.class)
.thenAcceptAsync(root -> {
GameRemoteVersions unlistedVersions = null;

View File

@@ -28,7 +28,6 @@ import org.jsoup.nodes.Document;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
@@ -53,7 +52,7 @@ public final class LiteLoaderVersionList extends VersionList<LiteLoaderRemoteVer
@Override
public Task<?> refreshAsync(String gameVersion) {
return new GetTask(URI.create(downloadProvider.injectURL(LITELOADER_LIST)))
return new GetTask(downloadProvider.injectURL(LITELOADER_LIST))
.thenGetJsonAsync(LiteLoaderVersionsRoot.class)
.thenAcceptAsync(root -> {
LiteLoaderGameVersions versions = root.getVersions().get(gameVersion);

View File

@@ -25,7 +25,6 @@ import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.gson.Validation;
import java.net.URI;
import java.util.Collections;
import java.util.Optional;
@@ -66,7 +65,7 @@ public final class NeoForgeBMCLVersionList extends VersionList<NeoForgeRemoteVer
@Override
public Task<?> refreshAsync(String gameVersion) {
return new GetTask(URI.create(apiRoot + "/neoforge/list/" + gameVersion)).thenGetJsonAsync(listTypeOf(NeoForgeVersion.class))
return new GetTask(apiRoot + "/neoforge/list/" + gameVersion).thenGetJsonAsync(listTypeOf(NeoForgeVersion.class))
.thenAcceptAsync(neoForgeVersions -> {
lock.writeLock().lock();

View File

@@ -5,7 +5,6 @@ import org.jackhuang.hmcl.download.VersionList;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Task;
import java.net.URI;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
@@ -39,8 +38,8 @@ public final class NeoForgeOfficialVersionList extends VersionList<NeoForgeRemot
@Override
public Task<?> refreshAsync() {
return Task.allOf(
new GetTask(URI.create(downloadProvider.injectURL(OLD_URL))).thenGetJsonAsync(OfficialAPIResult.class),
new GetTask(URI.create(downloadProvider.injectURL(META_URL))).thenGetJsonAsync(OfficialAPIResult.class)
new GetTask(downloadProvider.injectURL(OLD_URL)).thenGetJsonAsync(OfficialAPIResult.class),
new GetTask(downloadProvider.injectURL(META_URL)).thenGetJsonAsync(OfficialAPIResult.class)
).thenAcceptAsync(results -> {
lock.writeLock().lock();

View File

@@ -24,7 +24,6 @@ import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import java.net.URI;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
@@ -73,7 +72,7 @@ public final class OptiFineBMCLVersionList extends VersionList<OptiFineRemoteVer
@Override
public Task<?> refreshAsync() {
return new GetTask(URI.create(apiRoot + "/optifine/versionlist")).thenGetJsonAsync(listTypeOf(OptiFineVersion.class)).thenAcceptAsync(root -> {
return new GetTask(apiRoot + "/optifine/versionlist").thenGetJsonAsync(listTypeOf(OptiFineVersion.class)).thenAcceptAsync(root -> {
lock.writeLock().lock();
try {

View File

@@ -23,7 +23,6 @@ import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
@@ -59,7 +58,7 @@ public final class QuiltAPIInstallTask extends Task<Version> {
@Override
public void execute() throws IOException {
dependencies.add(new FileDownloadTask(
URI.create(remote.getVersion().getFile().getUrl()),
remote.getVersion().getFile().getUrl(),
dependencyManager.getGameRepository().getRunDirectory(version.getId()).toPath().resolve("mods").resolve("quilt-api-" + remote.getVersion().getVersion() + ".jar"),
remote.getVersion().getFile().getIntegrityCheck())
);

View File

@@ -28,9 +28,7 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.io.NetworkUtils.encodeLocation;
public class RemoteMod {
public final class RemoteMod {
public static final RemoteMod BROKEN = new RemoteMod("", "", "RemoteMod.BROKEN", "", Collections.emptyList(), "", "", new RemoteMod.IMod() {
@Override
@@ -313,7 +311,7 @@ public class RemoteMod {
}
public String getUrl() {
return encodeLocation(url);
return url;
}
public String getFilename() {

View File

@@ -21,10 +21,8 @@ import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.Nullable;
import java.net.URI;
import java.util.Objects;
/**
@@ -84,15 +82,13 @@ public final class CurseManifestFile implements Validation {
}
@Nullable
public URI getUrl() {
public String getUrl() {
if (url == null) {
if (fileName != null) {
return URI.create(NetworkUtils.encodeLocation(String.format("https://edge.forgecdn.net/files/%d/%d/%s", fileID / 1000, fileID % 1000, fileName)));
} else {
return null;
}
return fileName != null
? String.format("https://edge.forgecdn.net/files/%d/%d/%s", fileID / 1000, fileID % 1000, fileName)
: null;
} else {
return URI.create(NetworkUtils.encodeLocation(url));
return url;
}
}

View File

@@ -35,7 +35,6 @@ import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -101,7 +100,7 @@ public class McbbsModpackCompletionTask extends CompletableFutureTask<Void> {
throw new CustomException();
}
})).thenComposeAsync(wrap(unused1 -> {
return executor.one(new GetTask(URI.create(manifest.getFileApi() + "/manifest.json")));
return executor.one(new GetTask(manifest.getFileApi() + "/manifest.json"));
})).thenComposeAsync(wrap(remoteManifestJson -> {
McbbsModpackManifest remoteManifest;
// We needs to update modpack from online server.
@@ -201,10 +200,10 @@ public class McbbsModpackCompletionTask extends CompletableFutureTask<Void> {
McbbsModpackManifest.CurseFile file = (McbbsModpackManifest.CurseFile) rawFile;
if (StringUtils.isBlank(file.getFileName())) {
try {
return file.withFileName(NetworkUtils.detectFileName(file.getUrl()));
return file.withFileName(NetworkUtils.detectFileName(NetworkUtils.toURI(file.getUrl())));
} catch (IOException e) {
try {
String result = NetworkUtils.doGet(URI.create(String.format("https://cursemeta.dries007.net/%d/%d.json", file.getProjectID(), file.getFileID())));
String result = NetworkUtils.doGet(String.format("https://cursemeta.dries007.net/%d/%d.json", file.getProjectID(), file.getFileID()));
CurseMetaMod mod = JsonUtils.fromNonNullJson(result, CurseMetaMod.class);
return file.withFileName(mod.getFileNameOnDisk()).withURL(mod.getDownloadURL());
} catch (FileNotFoundException fof) {
@@ -213,7 +212,7 @@ public class McbbsModpackCompletionTask extends CompletableFutureTask<Void> {
return file;
} catch (IOException | JsonParseException e2) {
try {
String result = NetworkUtils.doGet(URI.create(String.format("https://addons-ecs.forgesvc.net/api/v2/addon/%d/file/%d", file.getProjectID(), file.getFileID())));
String result = NetworkUtils.doGet(String.format("https://addons-ecs.forgesvc.net/api/v2/addon/%d/file/%d", file.getProjectID(), file.getFileID()));
CurseMetaMod mod = JsonUtils.fromNonNullJson(result, CurseMetaMod.class);
return file.withFileName(mod.getFileName()).withURL(mod.getDownloadURL());
} catch (FileNotFoundException fof) {
@@ -297,7 +296,7 @@ public class McbbsModpackCompletionTask extends CompletableFutureTask<Void> {
if (file instanceof McbbsModpackManifest.AddonFile) {
McbbsModpackManifest.AddonFile addonFile = (McbbsModpackManifest.AddonFile) file;
return new FileDownloadTask(
URI.create(remoteManifest.getFileApi() + "/overrides/" + NetworkUtils.encodeLocation(addonFile.getPath())),
remoteManifest.getFileApi() + "/overrides/" + addonFile.getPath(),
modManager.getSimpleModPath(addonFile.getPath()),
addonFile.getHash() != null ? new FileDownloadTask.IntegrityCheck("SHA-1", addonFile.getHash()) : null);
} else if (file instanceof McbbsModpackManifest.CurseFile) {

View File

@@ -27,11 +27,9 @@ import org.jackhuang.hmcl.mod.ModpackManifest;
import org.jackhuang.hmcl.mod.ModpackProvider;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.*;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.Charset;
import java.util.Collections;
import java.util.List;
@@ -329,9 +327,10 @@ public class McbbsModpackManifest implements ModpackManifest, Validation {
return fileName;
}
public URI getUrl() {
return url == null ? URI.create("https://www.curseforge.com/minecraft/mc-mods/" + projectID + "/download/" + fileID + "/file")
: URI.create(NetworkUtils.encodeLocation(url));
public String getUrl() {
return url == null
? "https://www.curseforge.com/minecraft/mc-mods/" + projectID + "/download/" + fileID + "/file"
: url;
}
public CurseFile withFileName(String fileName) {

View File

@@ -1,6 +1,7 @@
package org.jackhuang.hmcl.mod.multimc;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.net.URI;
import java.util.*;
@@ -46,6 +47,6 @@ public final class MultiMCComponents {
}
public static URI getMetaURL(String componentID, String version) {
return URI.create(String.format("https://meta.multimc.org/v1/%s/%s.json", componentID, version));
return NetworkUtils.toURI(String.format("https://meta.multimc.org/v1/%s/%s.json", componentID, version));
}
}

View File

@@ -28,11 +28,9 @@ import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -84,7 +82,7 @@ public class ServerModpackCompletionTask extends Task<Void> {
@Override
public void preExecute() throws Exception {
if (manifest == null || StringUtils.isBlank(manifest.getManifest().getFileApi())) return;
dependent = new GetTask(URI.create(manifest.getManifest().getFileApi() + "/server-manifest.json"));
dependent = new GetTask(manifest.getManifest().getFileApi() + "/server-manifest.json");
}
@Override
@@ -152,7 +150,7 @@ public class ServerModpackCompletionTask extends Task<Void> {
if (download) {
total++;
dependencies.add(new FileDownloadTask(
URI.create(remoteManifest.getFileApi() + "/overrides/" + NetworkUtils.encodeLocation(file.getPath())),
remoteManifest.getFileApi() + "/overrides/" + file.getPath(),
actualPath,
new FileDownloadTask.IntegrityCheck("SHA-1", file.getHash()))
.withCounter("hmcl.modpack.download"));

View File

@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.task;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
@@ -36,12 +37,12 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
*/
public final class CacheFileTask extends FetchTask<Path> {
public CacheFileTask(@NotNull URI uri) {
super(List.of(uri), DEFAULT_RETRY);
public CacheFileTask(@NotNull String uri) {
super(List.of(NetworkUtils.toURI(uri)));
}
public CacheFileTask(@NotNull URI uri, int retry) {
super(List.of(uri), retry);
public CacheFileTask(@NotNull URI uri) {
super(List.of(uri));
}
@Override

View File

@@ -49,14 +49,13 @@ public abstract class FetchTask<T> extends Task<T> {
protected static final int DEFAULT_RETRY = 3;
protected final List<URI> uris;
protected final int retry;
protected int retry = DEFAULT_RETRY;
protected CacheRepository repository = CacheRepository.getInstance();
public FetchTask(@NotNull List<@NotNull URI> uris, int retry) {
public FetchTask(@NotNull List<@NotNull URI> uris) {
Objects.requireNonNull(uris);
this.uris = List.copyOf(uris);
this.retry = retry;
if (this.uris.isEmpty())
throw new IllegalArgumentException("At least one URL is required");
@@ -64,6 +63,13 @@ public abstract class FetchTask<T> extends Task<T> {
setExecutor(download());
}
public void setRetry(int retry) {
if (retry <= 0)
throw new IllegalArgumentException("Retry count must be greater than 0");
this.retry = retry;
}
public void setCacheRepository(CacheRepository repository) {
this.repository = repository;
}
@@ -167,7 +173,7 @@ public abstract class FetchTask<T> extends Task<T> {
if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
// Handle cache
try {
Path cache = repository.getCachedRemoteFile(conn.getURL().toURI());
Path cache = repository.getCachedRemoteFile(NetworkUtils.toURI(conn.getURL()));
useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;

View File

@@ -22,6 +22,7 @@ import org.jackhuang.hmcl.util.Hex;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.io.OutputStream;
@@ -82,6 +83,23 @@ public class FileDownloadTask extends FetchTask<Void> {
private Path candidate;
private final ArrayList<IntegrityCheckHandler> integrityCheckHandlers = new ArrayList<>();
/**
* @param uri the URI of remote file.
* @param path the location that download to.
*/
public FileDownloadTask(String uri, Path path) {
this(List.of(NetworkUtils.toURI(uri)), path, null);
}
/**
* @param uri the URI of remote file.
* @param path the location that download to.
* @param integrityCheck the integrity check to perform, null if no integrity check is to be performed
*/
public FileDownloadTask(String uri, Path path, IntegrityCheck integrityCheck) {
this(List.of(NetworkUtils.toURI(uri)), path, integrityCheck);
}
/**
* @param uri the URI of remote file.
* @param path the location that download to.
@@ -99,16 +117,6 @@ public class FileDownloadTask extends FetchTask<Void> {
this(List.of(uri), path, integrityCheck);
}
/**
* @param uri the URI of remote file.
* @param path the location that download to.
* @param integrityCheck the integrity check to perform, null if no integrity check is to be performed
* @param retry the times for retrying if downloading fails.
*/
public FileDownloadTask(URI uri, Path path, IntegrityCheck integrityCheck, int retry) {
this(List.of(uri), path, integrityCheck, retry);
}
/**
* Constructor.
*
@@ -127,19 +135,7 @@ public class FileDownloadTask extends FetchTask<Void> {
* @param integrityCheck the integrity check to perform, null if no integrity check is to be performed
*/
public FileDownloadTask(List<URI> uris, Path path, IntegrityCheck integrityCheck) {
this(uris, path, integrityCheck, DEFAULT_RETRY);
}
/**
* Constructor.
*
* @param uris uris of remote file, will be attempted in order.
* @param path the location that download to.
* @param integrityCheck the integrity check to perform, null if no integrity check is to be performed
* @param retry the times for retrying if downloading fails.
*/
public FileDownloadTask(List<URI> uris, Path path, IntegrityCheck integrityCheck, int retry) {
super(uris, retry);
super(uris);
this.file = path;
this.integrityCheck = integrityCheck;

View File

@@ -19,12 +19,12 @@ package org.jackhuang.hmcl.task;
import com.google.gson.reflect.TypeToken;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URLConnection;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@@ -36,29 +36,18 @@ import static java.nio.charset.StandardCharsets.UTF_8;
*/
public final class GetTask extends FetchTask<String> {
private final Charset charset;
public GetTask(String uri) {
this(NetworkUtils.toURI(uri));
}
public GetTask(URI url) {
this(url, UTF_8);
}
public GetTask(URI url, Charset charset) {
this(url, charset, DEFAULT_RETRY);
}
public GetTask(URI url, Charset charset, int retry) {
this(List.of(url), charset, retry);
this(List.of(url));
setName(url.toString());
}
public GetTask(List<URI> url) {
this(url, UTF_8, DEFAULT_RETRY);
}
public GetTask(List<URI> urls, Charset charset, int retry) {
super(urls, retry);
this.charset = charset;
setName(urls.get(0).toString());
super(url);
setName(url.get(0).toString());
}
@Override
@@ -85,7 +74,7 @@ public final class GetTask extends FetchTask<String> {
public void close() throws IOException {
if (!isSuccess()) return;
String result = baos.toString(charset);
String result = baos.toString(UTF_8);
setResult(result);
if (checkETag) {

View File

@@ -29,7 +29,6 @@ import org.jetbrains.annotations.Nullable;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLConnection;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
@@ -185,8 +184,8 @@ public class CacheRepository {
URI uri;
try {
uri = NetworkUtils.dropQuery(conn.getURL().toURI());
} catch (URISyntaxException e) {
uri = NetworkUtils.dropQuery(NetworkUtils.toURI(conn.getURL()));
} catch (IllegalArgumentException e) {
return;
}
ETagItem eTagItem;
@@ -229,16 +228,16 @@ public class CacheRepository {
if (StringUtils.isBlank(eTag)) return null;
URI uri;
try {
uri = NetworkUtils.dropQuery(connection.getURL().toURI());
} catch (URISyntaxException e) {
uri = NetworkUtils.dropQuery(NetworkUtils.toURI(connection.getURL()));
} catch (IllegalArgumentException e) {
throw new IOException(e);
}
String lastModified = connection.getHeaderField("Last-Modified");
CacheResult cacheResult = cacheSupplier.get();
ETagItem eTagItem = new ETagItem(uri, eTag, cacheResult.hash, Files.getLastModifiedTime(cacheResult.cachedFile).toMillis(), lastModified);
ETagItem eTagItem = new ETagItem(uri.toString(), eTag, cacheResult.hash, Files.getLastModifiedTime(cacheResult.cachedFile).toMillis(), lastModified);
lock.writeLock().lock();
try {
index.compute(eTagItem.url, updateEntity(eTagItem, true));
index.compute(uri, updateEntity(eTagItem, true));
saveETagIndex();
} finally {
lock.writeLock().unlock();
@@ -282,7 +281,7 @@ public class CacheRepository {
for (Collection<ETagItem> eTagItems : indexes) {
if (eTagItems != null) {
for (ETagItem eTag : eTagItems) {
eTags.compute(eTag.url, updateEntity(eTag, false));
eTags.compute(NetworkUtils.toURI(eTag.url), updateEntity(eTag, false));
}
}
}
@@ -331,7 +330,7 @@ public class CacheRepository {
}
private static final class ETagItem {
private final URI url;
private final String url;
private final String eTag;
private final String hash;
@SerializedName("local")
@@ -346,7 +345,7 @@ public class CacheRepository {
this(null, null, null, 0, null);
}
public ETagItem(URI url, String eTag, String hash, long localLastModified, String remoteLastModified) {
public ETagItem(String url, String eTag, String hash, long localLastModified, String remoteLastModified) {
this.url = url;
this.eTag = eTag;
this.hash = hash;

View File

@@ -29,7 +29,6 @@ import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.HashMap;
import java.util.HashSet;
@@ -120,7 +119,7 @@ public abstract class HttpRequest {
}
public HttpURLConnection createConnection() throws IOException {
HttpURLConnection con = createHttpConnection(URI.create(url));
HttpURLConnection con = createHttpConnection(url);
con.setRequestMethod(method);
for (Map.Entry<String, String> entry : headers.entrySet()) {
con.setRequestProperty(entry.getKey(), entry.getValue());
@@ -188,9 +187,9 @@ public abstract class HttpRequest {
if (con.getResponseCode() / 100 != 2) {
if (!ignoreHttpCode && !toleratedHttpCodes.contains(con.getResponseCode())) {
try {
throw new ResponseCodeException(NetworkUtils.toURI(url), con.getResponseCode(), NetworkUtils.readFullyAsString(con));
throw new ResponseCodeException(url.toString(), con.getResponseCode(), NetworkUtils.readFullyAsString(con));
} catch (IOException e) {
throw new ResponseCodeException(NetworkUtils.toURI(url), con.getResponseCode(), e);
throw new ResponseCodeException(url.toString(), con.getResponseCode(), e);
}
}
}

View File

@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.util.io;
import org.jackhuang.hmcl.util.Pair;
import org.jetbrains.annotations.NotNull;
import java.io.*;
import java.net.*;
@@ -122,10 +123,18 @@ public final class NetworkUtils {
return connection;
}
public static HttpURLConnection createHttpConnection(String url) throws IOException {
return (HttpURLConnection) createConnection(toURI(url));
}
public static HttpURLConnection createHttpConnection(URI url) throws IOException {
return (HttpURLConnection) createConnection(url);
}
private static void encodeCodePoint(StringBuilder builder, int codePoint) {
builder.append(encodeURL(Character.toString(codePoint)));
}
/**
* @param location the url to be URL encoded
* @return encoded URL
@@ -133,29 +142,59 @@ public final class NetworkUtils {
* "https://github.com/curl/curl/blob/3f7b1bb89f92c13e69ee51b710ac54f775aab320/lib/transfer.c#L1427-L1461">Curl</a>
*/
public static String encodeLocation(String location) {
StringBuilder sb = new StringBuilder();
int i = 0;
boolean left = true;
for (char ch : location.toCharArray()) {
switch (ch) {
case ' ':
if (left)
sb.append("%20");
else
sb.append('+');
break;
case '?':
left = false;
// fallthrough
default:
if (ch >= 0x80)
sb.append(encodeURL(Character.toString(ch)));
else
sb.append(ch);
break;
while (i < location.length()) {
char ch = location.charAt(i);
if (ch == ' ' || ch >= 0x80)
break;
else if (ch == '?')
left = false;
i++;
}
if (i == location.length()) {
// No need to encode
return location;
}
var builder = new StringBuilder(location.length() + 10);
builder.append(location, 0, i);
for (; i < location.length(); i++) {
char ch = location.charAt(i);
if (Character.isSurrogate(ch)) {
if (Character.isHighSurrogate(ch) && i < location.length() - 1) {
char ch2 = location.charAt(i + 1);
if (Character.isLowSurrogate(ch2)) {
int codePoint = Character.toCodePoint(ch, ch2);
encodeCodePoint(builder, codePoint);
i++;
continue;
}
}
// Invalid surrogate pair, encode as '?'
builder.append("%3F");
continue;
}
if (ch == ' ') {
if (left)
builder.append("%20");
else
builder.append('+');
} else if (ch == '?') {
left = false;
builder.append('?');
} else if (ch >= 0x80) {
encodeCodePoint(builder, ch);
} else {
builder.append(ch);
}
}
return sb.toString();
return builder.toString();
}
/**
@@ -200,6 +239,10 @@ public final class NetworkUtils {
return conn;
}
public static String doGet(String uri) throws IOException {
return doGet(toURI(uri));
}
public static String doGet(URI uri) throws IOException {
return readFullyAsString(resolveConnection(createHttpConnection(uri)));
}
@@ -319,14 +362,6 @@ public final class NetworkUtils {
}
}
public static URI toURI(URL url) {
try {
return url.toURI();
} catch (URISyntaxException e) {
throw new IllegalArgumentException(e);
}
}
// ==== Shortcut methods for encoding/decoding URLs in UTF-8 ====
public static String encodeURL(String toEncode) {
return URLEncoder.encode(toEncode, UTF_8);
@@ -335,6 +370,20 @@ public final class NetworkUtils {
public static String decodeURL(String toDecode) {
return URLDecoder.decode(toDecode, UTF_8);
}
/// @throws IllegalArgumentException if the string is not a valid URI
public static @NotNull URI toURI(@NotNull String uri) {
try {
return new URI(encodeLocation(uri));
} catch (URISyntaxException e) {
// Possibly an Internationalized Domain Name (IDN)
return URI.create(uri);
}
}
public static @NotNull URI toURI(@NotNull URL url) {
return toURI(url.toExternalForm());
}
// ====
}

View File

@@ -22,32 +22,44 @@ import java.net.URI;
public final class ResponseCodeException extends IOException {
private final URI uri;
private final String uri;
private final int responseCode;
private final String data;
public ResponseCodeException(URI uri, int responseCode) {
this(uri.toString(), responseCode);
}
public ResponseCodeException(URI uri, int responseCode, Throwable cause) {
this(uri.toString(), responseCode, cause);
}
public ResponseCodeException(URI uri, int responseCode, String data) {
this(uri.toString(), responseCode, data);
}
public ResponseCodeException(String uri, int responseCode) {
super("Unable to request url " + uri + ", response code: " + responseCode);
this.uri = uri;
this.responseCode = responseCode;
this.data = null;
}
public ResponseCodeException(URI uri, int responseCode, Throwable cause) {
public ResponseCodeException(String uri, int responseCode, Throwable cause) {
super("Unable to request url " + uri + ", response code: " + responseCode, cause);
this.uri = uri;
this.responseCode = responseCode;
this.data = null;
}
public ResponseCodeException(URI uri, int responseCode, String data) {
public ResponseCodeException(String uri, int responseCode, String data) {
super("Unable to request url " + uri + ", response code: " + responseCode + ", data: " + data);
this.uri = uri;
this.responseCode = responseCode;
this.data = data;
}
public URI getUri() {
public String getUri() {
return uri;
}

View File

@@ -21,18 +21,39 @@ import org.junit.jupiter.api.Test;
import static java.nio.charset.StandardCharsets.US_ASCII;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.jackhuang.hmcl.util.io.NetworkUtils.encodeLocation;
import static org.jackhuang.hmcl.util.io.NetworkUtils.getCharsetFromContentType;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* @author Glavo
*/
public class NetworkUtilsTest {
@Test
public void testEncodeLocation() {
assertEquals("https://github.com", encodeLocation("https://github.com"));
assertEquals("https://github.com/HMCL-dev/HMCL/commits?author=Glavo", encodeLocation("https://github.com/HMCL-dev/HMCL/commits?author=Glavo"));
assertEquals("https://www.example.com/file%20with%20space", encodeLocation("https://www.example.com/file with space"));
assertEquals("https://www.example.com/file%20with%20space", encodeLocation("https://www.example.com/file%20with%20space"));
assertEquals("https://www.example.com/%E6%B5%8B%E8%AF%95", encodeLocation("https://www.example.com/测试"));
assertEquals("https://www.example.com/%F0%9F%98%87", encodeLocation("https://www.example.com/\uD83D\uDE07"));
assertEquals("https://www.example.com/test?a=10+20", encodeLocation("https://www.example.com/test?a=10 20"));
assertEquals("https://www.example.com/%E6%B5%8B%E8%AF%95?a=10+20", encodeLocation("https://www.example.com/测试?a=10 20"));
// Invalid surrogate pair
assertEquals("https://www.example.com/%3F", encodeLocation("https://www.example.com/\uD83D"));
assertEquals("https://www.example.com/%3F", encodeLocation("https://www.example.com/\uDE07"));
assertEquals("https://www.example.com/%3Ftest", encodeLocation("https://www.example.com/\uD83Dtest"));
assertEquals("https://www.example.com/%3Ftest", encodeLocation("https://www.example.com/\uDE07test"));
}
@Test
public void testGetEncodingFromUrl() {
assertEquals(UTF_8, NetworkUtils.getCharsetFromContentType(null));
assertEquals(UTF_8, NetworkUtils.getCharsetFromContentType(""));
assertEquals(UTF_8, NetworkUtils.getCharsetFromContentType("text/html"));
assertEquals(UTF_8, NetworkUtils.getCharsetFromContentType("text/html; charset=utf-8"));
assertEquals(US_ASCII, NetworkUtils.getCharsetFromContentType("text/html; charset=ascii"));
assertEquals(UTF_8, getCharsetFromContentType(null));
assertEquals(UTF_8, getCharsetFromContentType(""));
assertEquals(UTF_8, getCharsetFromContentType("text/html"));
assertEquals(UTF_8, getCharsetFromContentType("text/html; charset=utf-8"));
assertEquals(US_ASCII, getCharsetFromContentType("text/html; charset=ascii"));
}
}