feat(offline-skin): WIP: implement yggdrasil server to serve skin/cape textures.

This commit is contained in:
huanghongxun
2021-09-21 09:35:14 +08:00
parent 4537fe0390
commit e796e0f35e
9 changed files with 608 additions and 18 deletions

View File

@@ -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 YggdrasilAccountFactory FACTORY_MOJANG = YggdrasilAccountFactory.MOJANG;
public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer); 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())); public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(new MicrosoftAuthenticationServer.Factory()));

View File

@@ -62,6 +62,7 @@ import org.jetbrains.annotations.Nullable;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import static java.util.Collections.emptyList; import static java.util.Collections.emptyList;
@@ -521,7 +522,8 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
if (factory instanceof AuthlibInjectorAccountFactory) { if (factory instanceof AuthlibInjectorAccountFactory) {
return getAuthServer(); return getAuthServer();
} else if (factory instanceof OfflineAccountFactory) { } 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 { } else {
return null; return null;
} }

View File

@@ -69,7 +69,7 @@ public abstract class Account implements Observable {
* Play offline. * Play offline.
* @return the specific offline player's info. * @return the specific offline player's info.
*/ */
public abstract Optional<AuthInfo> playOffline(); public abstract Optional<AuthInfo> playOffline() throws AuthenticationException;
public abstract Map<Object, Object> toStorage(); public abstract Map<Object, Object> toStorage();

View File

@@ -27,24 +27,26 @@ import java.util.UUID;
* @author huangyuhui * @author huangyuhui
*/ */
@Immutable @Immutable
public final class AuthInfo { public final class AuthInfo implements AutoCloseable {
private final String username; private final String username;
private final UUID uuid; private final UUID uuid;
private final String accessToken; private final String accessToken;
private final String userProperties; private final String userProperties;
private final Arguments arguments; private final Arguments arguments;
private final AutoCloseable closeable;
public AuthInfo(String username, UUID uuid, String accessToken, String userProperties) { 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.username = username;
this.uuid = uuid; this.uuid = uuid;
this.accessToken = accessToken; this.accessToken = accessToken;
this.userProperties = userProperties; this.userProperties = userProperties;
this.arguments = arguments; this.arguments = arguments;
this.closeable = closeable;
} }
public String getUsername() { public String getUsername() {
@@ -77,6 +79,17 @@ public final class AuthInfo {
} }
public AuthInfo withArguments(Arguments arguments) { 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();
}
} }
} }

View File

@@ -20,13 +20,22 @@ package org.jackhuang.hmcl.auth.offline;
import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.auth.AuthenticationException; 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.StringUtils;
import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.ToStringBuilder;
import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
import java.io.IOException;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; 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 java.util.Objects.requireNonNull;
import static org.jackhuang.hmcl.util.Lang.mapOf; 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 { public class OfflineAccount extends Account {
private final AuthlibInjectorArtifactProvider downloader;
private final String username; private final String username;
private final UUID uuid; 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.username = requireNonNull(username);
this.uuid = requireNonNull(uuid); this.uuid = requireNonNull(uuid);
this.skin = skin;
this.cape = cape;
if (StringUtils.isBlank(username)) { if (StringUtils.isBlank(username)) {
throw new IllegalArgumentException("Username cannot be blank"); throw new IllegalArgumentException("Username cannot be blank");
@@ -66,8 +81,52 @@ public class OfflineAccount extends Account {
} }
@Override @Override
public AuthInfo logIn() { public AuthInfo logIn() throws AuthenticationException {
return new AuthInfo(username, uuid, UUIDTypeAdapter.fromUUID(UUID.randomUUID()), "{}"); AuthInfo authInfo = new AuthInfo(username, uuid, UUIDTypeAdapter.fromUUID(UUID.randomUUID()), "{}");
if (skin != null || cape != null) {
CompletableFuture<AuthlibInjectorArtifactInfo> 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 @Override
@@ -76,7 +135,7 @@ public class OfflineAccount extends Account {
} }
@Override @Override
public Optional<AuthInfo> playOffline() { public Optional<AuthInfo> playOffline() throws AuthenticationException {
return Optional.of(logIn()); return Optional.of(logIn());
} }
@@ -84,7 +143,9 @@ public class OfflineAccount extends Account {
public Map<Object, Object> toStorage() { public Map<Object, Object> toStorage() {
return mapOf( return mapOf(
pair("uuid", UUIDTypeAdapter.fromUUID(uuid)), 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) return new ToStringBuilder(this)
.append("username", username) .append("username", username)
.append("uuid", uuid) .append("uuid", uuid)
.append("skin", skin)
.append("cape", cape)
.toString(); .toString();
} }

View File

@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.auth.offline;
import org.jackhuang.hmcl.auth.AccountFactory; import org.jackhuang.hmcl.auth.AccountFactory;
import org.jackhuang.hmcl.auth.CharacterSelector; import org.jackhuang.hmcl.auth.CharacterSelector;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorArtifactProvider;
import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
import java.util.Map; import java.util.Map;
@@ -32,9 +33,10 @@ import static org.jackhuang.hmcl.util.Lang.tryCast;
* @author huangyuhui * @author huangyuhui
*/ */
public final class OfflineAccountFactory extends AccountFactory<OfflineAccount> { public final class OfflineAccountFactory extends AccountFactory<OfflineAccount> {
public static final OfflineAccountFactory INSTANCE = new OfflineAccountFactory(); private final AuthlibInjectorArtifactProvider downloader;
private OfflineAccountFactory() { public OfflineAccountFactory(AuthlibInjectorArtifactProvider downloader) {
this.downloader = downloader;
} }
@Override @Override
@@ -43,18 +45,25 @@ public final class OfflineAccountFactory extends AccountFactory<OfflineAccount>
} }
public OfflineAccount create(String username, UUID uuid) { public OfflineAccount create(String username, UUID uuid) {
return new OfflineAccount(username, uuid); return new OfflineAccount(downloader, username, uuid, null, null);
} }
@Override @Override
public OfflineAccount create(CharacterSelector selector, String username, String password, ProgressCallback progressCallback, Object additionalData) { public OfflineAccount create(CharacterSelector selector, String username, String password, ProgressCallback progressCallback, Object additionalData) {
AdditionalData data;
UUID uuid; UUID uuid;
String skin;
String cape;
if (additionalData != null) { if (additionalData != null) {
uuid = (UUID) additionalData; data = (AdditionalData) additionalData;
uuid = data.uuid == null ? getUUIDFromUserName(username) : data.uuid;
skin = data.skin;
cape = data.cape;
} else { } else {
uuid = getUUIDFromUserName(username); uuid = getUUIDFromUserName(username);
skin = cape = null;
} }
return new OfflineAccount(username, uuid); return new OfflineAccount(downloader, username, uuid, skin, cape);
} }
@Override @Override
@@ -64,12 +73,26 @@ public final class OfflineAccountFactory extends AccountFactory<OfflineAccount>
UUID uuid = tryCast(storage.get("uuid"), String.class) UUID uuid = tryCast(storage.get("uuid"), String.class)
.map(UUIDTypeAdapter::fromString) .map(UUIDTypeAdapter::fromString)
.orElse(getUUIDFromUserName(username)); .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) { public static UUID getUUIDFromUserName(String username) {
return UUID.nameUUIDFromBytes(("OfflinePlayer:" + username).getBytes(UTF_8)); 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;
}
}
} }

View File

@@ -0,0 +1,324 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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<String, Texture> textures = new HashMap<>();
private final Map<UUID, Character> charactersByUuid = new HashMap<>();
private final Map<String, Character> 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/(?<uuid>[a-f0-9]{32})"), this::profile);
addRoute(Method.GET, Pattern.compile("/textures/(?<hash>[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<String> names = JsonUtils.fromNonNullJson(body, new TypeToken<List<String>>() {
}.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<Character> findCharacterByUuid(UUID uuid) {
return Optional.ofNullable(charactersByUuid.get(uuid));
}
private Optional<Character> 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<TextureType, Texture> textures;
public Character(UUID uuid, String name, ModelType model, Map<TextureType, Texture> 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<TextureType, Texture> getTextures() {
return textures;
}
private Map<String, Object> 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<TextureType, Object> realTextures = new HashMap<>();
for (Map.Entry<TextureType, Texture> textureEntry : textures.entrySet()) {
if (textureEntry.getValue() == null) continue;
realTextures.put(textureEntry.getKey(), mapOf(pair("url", rootUrl + "/textures/" + textureEntry.getValue().hash)));
}
Map<String, Object> 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;
}
}
}

View File

@@ -0,0 +1,163 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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<Route> 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<Request, Response, ?> 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<Request, Response, ?> server;
public DefaultRoute(Method method, Pattern pathPattern, ExceptionalFunction<Request, Response, ?> 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<String, String> query;
NanoHTTPD.IHTTPSession session;
public Request(Matcher pathVariables, Map<String, String> query, NanoHTTPD.IHTTPSession session) {
this.pathVariables = pathVariables;
this.query = query;
this.session = session;
}
public Matcher getPathVariables() {
return pathVariables;
}
public Map<String, String> getQuery() {
return query;
}
public NanoHTTPD.IHTTPSession getSession() {
return session;
}
}
}

View File

@@ -66,6 +66,8 @@ public final class NetworkUtils {
} }
public static List<Pair<String, String>> parseQuery(String queryParameterString) { public static List<Pair<String, String>> parseQuery(String queryParameterString) {
if (queryParameterString == null) return Collections.emptyList();
List<Pair<String, String>> result = new ArrayList<>(); List<Pair<String, String>> result = new ArrayList<>();
try (Scanner scanner = new Scanner(queryParameterString)) { try (Scanner scanner = new Scanner(queryParameterString)) {