diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java index c8bacc7cb..daf196536 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java @@ -81,7 +81,7 @@ public final class Accounts { } } - public static final OfflineAccountFactory FACTORY_OFFLINE = OfflineAccountFactory.INSTANCE; + public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER); public static final YggdrasilAccountFactory FACTORY_MOJANG = YggdrasilAccountFactory.MOJANG; public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer); public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(new MicrosoftAuthenticationServer.Factory())); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java index 55889affc..adbf31796 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java @@ -62,6 +62,7 @@ import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.CountDownLatch; import static java.util.Collections.emptyList; @@ -521,7 +522,8 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware { if (factory instanceof AuthlibInjectorAccountFactory) { return getAuthServer(); } else if (factory instanceof OfflineAccountFactory) { - return txtUUID == null ? null : StringUtils.isBlank(txtUUID.getText()) ? null : UUIDTypeAdapter.fromString(txtUUID.getText()); + UUID uuid = txtUUID == null ? null : StringUtils.isBlank(txtUUID.getText()) ? null : UUIDTypeAdapter.fromString(txtUUID.getText()); + return new OfflineAccountFactory.AdditionalData(uuid, null, null); } else { return null; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java index 9d7751cb1..e94a649f3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java @@ -69,7 +69,7 @@ public abstract class Account implements Observable { * Play offline. * @return the specific offline player's info. */ - public abstract Optional playOffline(); + public abstract Optional playOffline() throws AuthenticationException; public abstract Map toStorage(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/AuthInfo.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/AuthInfo.java index 3f94c4d81..73e130a08 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/AuthInfo.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/AuthInfo.java @@ -27,24 +27,26 @@ import java.util.UUID; * @author huangyuhui */ @Immutable -public final class AuthInfo { +public final class AuthInfo implements AutoCloseable { private final String username; private final UUID uuid; private final String accessToken; private final String userProperties; private final Arguments arguments; + private final AutoCloseable closeable; public AuthInfo(String username, UUID uuid, String accessToken, String userProperties) { - this(username, uuid, accessToken, userProperties, null); + this(username, uuid, accessToken, userProperties, null, null); } - public AuthInfo(String username, UUID uuid, String accessToken, String userProperties, Arguments arguments) { + public AuthInfo(String username, UUID uuid, String accessToken, String userProperties, Arguments arguments, AutoCloseable closeable) { this.username = username; this.uuid = uuid; this.accessToken = accessToken; this.userProperties = userProperties; this.arguments = arguments; + this.closeable = closeable; } public String getUsername() { @@ -77,6 +79,17 @@ public final class AuthInfo { } public AuthInfo withArguments(Arguments arguments) { - return new AuthInfo(username, uuid, accessToken, userProperties, arguments); + return new AuthInfo(username, uuid, accessToken, userProperties, arguments, closeable); + } + + public AuthInfo withCloseable(AutoCloseable closeable) { + return new AuthInfo(username, uuid, accessToken, userProperties, arguments, closeable); + } + + @Override + public void close() throws Exception { + if (closeable != null) { + closeable.close(); + } } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java index c06f76295..a223875dc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java @@ -20,13 +20,22 @@ package org.jackhuang.hmcl.auth.offline; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.auth.AuthenticationException; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorArtifactInfo; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorArtifactProvider; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException; +import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.game.Arguments; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; +import java.io.IOException; import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; import static java.util.Objects.requireNonNull; import static org.jackhuang.hmcl.util.Lang.mapOf; @@ -38,12 +47,18 @@ import static org.jackhuang.hmcl.util.Pair.pair; */ public class OfflineAccount extends Account { + private final AuthlibInjectorArtifactProvider downloader; private final String username; private final UUID uuid; + private final String skin; + private final String cape; - protected OfflineAccount(String username, UUID uuid) { + protected OfflineAccount(AuthlibInjectorArtifactProvider downloader, String username, UUID uuid, String skin, String cape) { + this.downloader = requireNonNull(downloader); this.username = requireNonNull(username); this.uuid = requireNonNull(uuid); + this.skin = skin; + this.cape = cape; if (StringUtils.isBlank(username)) { throw new IllegalArgumentException("Username cannot be blank"); @@ -66,8 +81,52 @@ public class OfflineAccount extends Account { } @Override - public AuthInfo logIn() { - return new AuthInfo(username, uuid, UUIDTypeAdapter.fromUUID(UUID.randomUUID()), "{}"); + public AuthInfo logIn() throws AuthenticationException { + AuthInfo authInfo = new AuthInfo(username, uuid, UUIDTypeAdapter.fromUUID(UUID.randomUUID()), "{}"); + + if (skin != null || cape != null) { + CompletableFuture artifactTask = CompletableFuture.supplyAsync(() -> { + try { + return downloader.getArtifactInfo(); + } catch (IOException e) { + throw new CompletionException(new AuthlibInjectorDownloadException(e)); + } + }); + + AuthlibInjectorArtifactInfo artifact; + try { + artifact = artifactTask.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AuthenticationException(e); + } catch (ExecutionException e) { + if (e.getCause() instanceof AuthenticationException) { + throw (AuthenticationException) e.getCause(); + } else { + throw new AuthenticationException(e.getCause()); + } + } + + try { + YggdrasilServer server = new YggdrasilServer(0); + server.start(); + server.addCharacter(new YggdrasilServer.Character(uuid, username, YggdrasilServer.ModelType.STEVE, + mapOf( + pair(TextureType.SKIN, server.loadTexture(skin)), + pair(TextureType.CAPE, server.loadTexture(cape)) + ))); + + return authInfo.withArguments(new Arguments().addJVMArguments( + "-javaagent:" + artifact.getLocation().toString() + "=" + "http://127.0.0.1:" + server.getListeningPort(), + "-Dauthlibinjector.side=client" + )) + .withCloseable(server::stop); + } catch (IOException e) { + throw new AuthenticationException(e); + } + } else { + return authInfo; + } } @Override @@ -76,7 +135,7 @@ public class OfflineAccount extends Account { } @Override - public Optional playOffline() { + public Optional playOffline() throws AuthenticationException { return Optional.of(logIn()); } @@ -84,7 +143,9 @@ public class OfflineAccount extends Account { public Map toStorage() { return mapOf( pair("uuid", UUIDTypeAdapter.fromUUID(uuid)), - pair("username", username) + pair("username", username), + pair("skin", skin), + pair("cape", cape) ); } @@ -93,6 +154,8 @@ public class OfflineAccount extends Account { return new ToStringBuilder(this) .append("username", username) .append("uuid", uuid) + .append("skin", skin) + .append("cape", cape) .toString(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java index 6a395ed16..135badc77 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java @@ -19,6 +19,7 @@ package org.jackhuang.hmcl.auth.offline; import org.jackhuang.hmcl.auth.AccountFactory; import org.jackhuang.hmcl.auth.CharacterSelector; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorArtifactProvider; import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; import java.util.Map; @@ -32,9 +33,10 @@ import static org.jackhuang.hmcl.util.Lang.tryCast; * @author huangyuhui */ public final class OfflineAccountFactory extends AccountFactory { - public static final OfflineAccountFactory INSTANCE = new OfflineAccountFactory(); + private final AuthlibInjectorArtifactProvider downloader; - private OfflineAccountFactory() { + public OfflineAccountFactory(AuthlibInjectorArtifactProvider downloader) { + this.downloader = downloader; } @Override @@ -43,18 +45,25 @@ public final class OfflineAccountFactory extends AccountFactory } public OfflineAccount create(String username, UUID uuid) { - return new OfflineAccount(username, uuid); + return new OfflineAccount(downloader, username, uuid, null, null); } @Override public OfflineAccount create(CharacterSelector selector, String username, String password, ProgressCallback progressCallback, Object additionalData) { + AdditionalData data; UUID uuid; + String skin; + String cape; if (additionalData != null) { - uuid = (UUID) additionalData; + data = (AdditionalData) additionalData; + uuid = data.uuid == null ? getUUIDFromUserName(username) : data.uuid; + skin = data.skin; + cape = data.cape; } else { uuid = getUUIDFromUserName(username); + skin = cape = null; } - return new OfflineAccount(username, uuid); + return new OfflineAccount(downloader, username, uuid, skin, cape); } @Override @@ -64,12 +73,26 @@ public final class OfflineAccountFactory extends AccountFactory UUID uuid = tryCast(storage.get("uuid"), String.class) .map(UUIDTypeAdapter::fromString) .orElse(getUUIDFromUserName(username)); + String skin = tryCast(storage.get("skin"), String.class).orElse(null); + String cape = tryCast(storage.get("cape"), String.class).orElse(null); - return new OfflineAccount(username, uuid); + return new OfflineAccount(downloader, username, uuid, skin, cape); } public static UUID getUUIDFromUserName(String username) { return UUID.nameUUIDFromBytes(("OfflinePlayer:" + username).getBytes(UTF_8)); } + public static class AdditionalData { + private final UUID uuid; + private final String skin; + private final String cape; + + public AdditionalData(UUID uuid, String skin, String cape) { + this.uuid = uuid; + this.skin = skin; + this.cape = cape; + } + } + } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java new file mode 100644 index 000000000..be807f6fb --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java @@ -0,0 +1,324 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.auth.offline; + +import com.google.gson.reflect.TypeToken; +import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; +import org.jackhuang.hmcl.auth.yggdrasil.GameProfile; +import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; +import org.jackhuang.hmcl.util.io.HttpServer; +import org.jackhuang.hmcl.util.io.IOUtils; + +import javax.imageio.IIOException; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static java.util.Objects.requireNonNull; +import static org.jackhuang.hmcl.util.Lang.mapOf; +import static org.jackhuang.hmcl.util.Pair.pair; + +public class YggdrasilServer extends HttpServer { + + private final Map textures = new HashMap<>(); + private final Map charactersByUuid = new HashMap<>(); + private final Map charactersByName = new HashMap<>(); + + public YggdrasilServer(int port) { + super(port); + + addRoute(Method.GET, Pattern.compile("^/$"), this::root); + addRoute(Method.GET, Pattern.compile("/status"), this::status); + addRoute(Method.POST, Pattern.compile("/api/profiles/minecraft"), this::profiles); + addRoute(Method.GET, Pattern.compile("/sessionserver/session/minecraft/hasJoined"), this::hasJoined); + addRoute(Method.POST, Pattern.compile("/sessionserver/session/minecraft/join"), this::joinServer); + addRoute(Method.GET, Pattern.compile("/sessionserver/session/minecraft/profile/(?[a-f0-9]{32})"), this::profile); + addRoute(Method.GET, Pattern.compile("/textures/(?[a-f0-9]{64})"), this::texture); + } + + private Response root(Request request) { + return ok(mapOf( + pair("skinDomains", Collections.emptyList()), + pair("meta", mapOf( + pair("serverName", "HMCL Offline Account Skin/Cape Server"), + pair("implementationName", "HMCL"), + pair("implementationVersion", "1.0"), + pair("feature.non_email_login", true) + )) + )); + } + + private Response status(Request request) { + return ok(mapOf( + pair("user.count", charactersByUuid.size()), + pair("token.count", 0), + pair("pendingAuthentication.count", 0) + )); + } + + private Response profiles(Request request) throws IOException { + String body = IOUtils.readFullyAsString(request.getSession().getInputStream(), StandardCharsets.UTF_8); + List names = JsonUtils.fromNonNullJson(body, new TypeToken>() { + }.getType()); + return ok(names.stream().distinct() + .map(this::findCharacterByName) + .flatMap(Lang::toStream) + .map(Character::toSimpleResponse) + .collect(Collectors.toList())); + } + + private Response hasJoined(Request request) { + if (!request.getQuery().containsKey("username")) { + return badRequest(); + } + return findCharacterByName(request.getQuery().get("username")) + .map(character -> ok(character.toCompleteResponse(getRootUrl()))) + .orElseGet(HttpServer::noContent); + } + + private Response joinServer(Request request) { + return noContent(); + } + + private Response profile(Request request) { + String uuid = request.getPathVariables().group("uuid"); + + return findCharacterByUuid(UUIDTypeAdapter.fromString(uuid)) + .map(character -> ok(character.toCompleteResponse(getRootUrl()))) + .orElseGet(HttpServer::noContent); + } + + private Response texture(Request request) { + String hash = request.getPathVariables().group("hash"); + + if (textures.containsKey(hash)) { + Texture texture = textures.get(hash); + Response response = newFixedLengthResponse(Response.Status.OK, "image/png", texture.getInputStream(), texture.getLength()); + response.addHeader("Etag", String.format("\"%s\"", hash)); + response.addHeader("Cache-Control", "max-age=2592000, public"); + return response; + } else { + return notFound(); + } + } + + private Optional findCharacterByUuid(UUID uuid) { + return Optional.ofNullable(charactersByUuid.get(uuid)); + } + + private Optional findCharacterByName(String uuid) { + return Optional.ofNullable(charactersByName.get(uuid)); + } + + private static String computeTextureHash(BufferedImage img) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + int width = img.getWidth(); + int height = img.getHeight(); + byte[] buf = new byte[4096]; + + putInt(buf, 0, width); + putInt(buf, 4, height); + int pos = 8; + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + putInt(buf, pos, img.getRGB(x, y)); + if (buf[pos + 0] == 0) { + buf[pos + 1] = buf[pos + 2] = buf[pos + 3] = 0; + } + pos += 4; + if (pos == buf.length) { + pos = 0; + digest.update(buf, 0, buf.length); + } + } + } + if (pos > 0) { + digest.update(buf, 0, pos); + } + + byte[] sha256 = digest.digest(); + return String.format("%0" + (sha256.length << 1) + "x", new BigInteger(1, sha256)); + } + + private static void putInt(byte[] array, int offset, int x) { + array[offset + 0] = (byte) (x >> 24 & 0xff); + array[offset + 1] = (byte) (x >> 16 & 0xff); + array[offset + 2] = (byte) (x >> 8 & 0xff); + array[offset + 3] = (byte) (x >> 0 & 0xff); + } + + private Texture loadTexture(InputStream in) throws IOException { + if (in == null) return null; + BufferedImage img = ImageIO.read(in); + if (img == null) { + throw new IIOException("No image found"); + } + + String hash = computeTextureHash(img); + + Texture existent = textures.get(hash); + if (existent != null) { + return existent; + } + + String url = String.format("http://127.0.0.1:%d/textures/%s", getListeningPort(), hash); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + ImageIO.write(img, "png", buf); + Texture texture = new Texture(hash, buf.toByteArray(), url); + + existent = textures.putIfAbsent(hash, texture); + + if (existent != null) { + return existent; + } + return texture; + } + + public Texture loadTexture(String url) throws IOException { + if (url == null) return null; + return loadTexture(new URL(url).openStream()); + } + + public void addCharacter(Character character) { + charactersByUuid.put(character.getUUID(), character); + charactersByName.put(character.getName(), character); + } + + public enum ModelType { + STEVE("default"), + ALEX("slim"); + + private String modelName; + + ModelType(String modelName) { + this.modelName = modelName; + } + + public String getModelName() { + return modelName; + } + } + + public static class Character { + private final UUID uuid; + private final String name; + private final ModelType model; + private final Map textures; + + public Character(UUID uuid, String name, ModelType model, Map textures) { + this.uuid = uuid; + this.name = name; + this.model = model; + this.textures = textures; + } + + public UUID getUUID() { + return uuid; + } + + public String getName() { + return name; + } + + public ModelType getModel() { + return model; + } + + public Map getTextures() { + return textures; + } + + private Map createKeyValue(String key, String value) { + return mapOf( + pair("name", key), + pair("value", value) + ); + } + + public GameProfile toSimpleResponse() { + return new GameProfile(uuid, name); + } + + public CompleteGameProfile toCompleteResponse(String rootUrl) { + Map realTextures = new HashMap<>(); + for (Map.Entry textureEntry : textures.entrySet()) { + if (textureEntry.getValue() == null) continue; + realTextures.put(textureEntry.getKey(), mapOf(pair("url", rootUrl + "/textures/" + textureEntry.getValue().hash))); + } + + Map textureResponse = mapOf( + pair("timestamp", System.currentTimeMillis()), + pair("profileId", uuid), + pair("profileName", name), + pair("textures", realTextures) + ); + + return new CompleteGameProfile(uuid, name, mapOf( + pair("textures", new String( + Base64.getEncoder().encode( + JsonUtils.GSON.toJson(textureResponse).getBytes(StandardCharsets.UTF_8) + ), StandardCharsets.UTF_8) + ) + )); + } + } + + private static class Texture { + private final String hash; + private final byte[] data; + private final String url; + + public Texture(String hash, byte[] data, String url) { + this.hash = requireNonNull(hash); + this.data = requireNonNull(data); + this.url = requireNonNull(url); + } + + public String getUrl() { + return url; + } + + public InputStream getInputStream() { + return new ByteArrayInputStream(data); + } + + public int getLength() { + return data.length; + } + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpServer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpServer.java new file mode 100644 index 000000000..7ae903005 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpServer.java @@ -0,0 +1,163 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.io; + +import com.google.gson.JsonParseException; +import fi.iki.elonen.NanoHTTPD; +import org.jackhuang.hmcl.util.Logging; +import org.jackhuang.hmcl.util.function.ExceptionalFunction; +import org.jackhuang.hmcl.util.gson.JsonUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Level; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.jackhuang.hmcl.util.Lang.mapOf; + +public class HttpServer extends NanoHTTPD { + private int traceId = 0; + protected final List routes = new ArrayList<>(); + + public HttpServer(int port) { + super(port); + } + + public HttpServer(String hostname, int port) { + super(hostname, port); + } + + public String getRootUrl() { + return "http://127.0.0.1:" + getListeningPort(); + } + + protected void addRoute(Method method, Pattern path, ExceptionalFunction server) { + routes.add(new DefaultRoute(method, path, server)); + } + + protected static Response ok(Object response) { + Logging.LOG.info(String.format("Response %s", JsonUtils.GSON.toJson(response))); + return newFixedLengthResponse(Response.Status.OK, "text/json", JsonUtils.GSON.toJson(response)); + } + + protected static Response notFound() { + return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_HTML, "404 not found"); + } + + protected static Response noContent() { + return newFixedLengthResponse(Response.Status.NO_CONTENT, MIME_HTML, ""); + } + + protected static Response badRequest() { + return newFixedLengthResponse(Response.Status.BAD_REQUEST, MIME_HTML, "400 bad request"); + } + + protected static Response internalError() { + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_HTML, "500 internal error"); + } + + @Override + public Response serve(IHTTPSession session) { + int currentId = traceId++; + Logging.LOG.info(String.format("[%d] %s --> %s", currentId, session.getMethod().name(), + session.getUri() + Optional.ofNullable(session.getQueryParameterString()).map(s -> "?" + s).orElse(""))); + + Response response = null; + for (Route route : routes) { + if (route.method != session.getMethod()) continue; + + Matcher pathMatcher = route.pathPattern.matcher(session.getUri()); + if (!pathMatcher.find()) continue; + + response = route.serve(new Request(pathMatcher, mapOf(NetworkUtils.parseQuery(session.getQueryParameterString())), session)); + break; + } + + if (response == null) response = notFound(); + Logging.LOG.info(String.format("[%d] %s <--", currentId, response.getStatus())); + return response; + } + + public static abstract class Route { + Method method; + Pattern pathPattern; + + public Route(Method method, Pattern pathPattern) { + this.method = method; + this.pathPattern = pathPattern; + } + + public Method getMethod() { + return method; + } + + public Pattern getPathPattern() { + return pathPattern; + } + + public abstract Response serve(Request request); + } + + public static class DefaultRoute extends Route { + private final ExceptionalFunction server; + + public DefaultRoute(Method method, Pattern pathPattern, ExceptionalFunction server) { + super(method, pathPattern); + this.server = server; + } + + @Override + public Response serve(Request request) { + try { + return server.apply(request); + } catch (JsonParseException e) { + return badRequest(); + } catch (Exception e) { + Logging.LOG.log(Level.SEVERE, "Error handling " + request.getSession().getUri(), e); + return internalError(); + } + } + } + + public static class Request { + Matcher pathVariables; + Map query; + NanoHTTPD.IHTTPSession session; + + public Request(Matcher pathVariables, Map query, NanoHTTPD.IHTTPSession session) { + this.pathVariables = pathVariables; + this.query = query; + this.session = session; + } + + public Matcher getPathVariables() { + return pathVariables; + } + + public Map getQuery() { + return query; + } + + public NanoHTTPD.IHTTPSession getSession() { + return session; + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/NetworkUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/NetworkUtils.java index a4bbf5efa..2f52c90bd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/NetworkUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/NetworkUtils.java @@ -66,6 +66,8 @@ public final class NetworkUtils { } public static List> parseQuery(String queryParameterString) { + if (queryParameterString == null) return Collections.emptyList(); + List> result = new ArrayList<>(); try (Scanner scanner = new Scanner(queryParameterString)) {