feat(offline-skin): WIP: implement yggdrasil server to serve skin/cape textures.
This commit is contained in:
@@ -69,7 +69,7 @@ public abstract class Account implements Observable {
|
||||
* Play offline.
|
||||
* @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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
@@ -76,7 +135,7 @@ public class OfflineAccount extends Account {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AuthInfo> playOffline() {
|
||||
public Optional<AuthInfo> playOffline() throws AuthenticationException {
|
||||
return Optional.of(logIn());
|
||||
}
|
||||
|
||||
@@ -84,7 +143,9 @@ public class OfflineAccount extends Account {
|
||||
public Map<Object, Object> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<OfflineAccount> {
|
||||
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<OfflineAccount>
|
||||
}
|
||||
|
||||
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<OfflineAccount>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,8 @@ public final class NetworkUtils {
|
||||
}
|
||||
|
||||
public static List<Pair<String, String>> parseQuery(String queryParameterString) {
|
||||
if (queryParameterString == null) return Collections.emptyList();
|
||||
|
||||
List<Pair<String, String>> result = new ArrayList<>();
|
||||
|
||||
try (Scanner scanner = new Scanner(queryParameterString)) {
|
||||
|
||||
Reference in New Issue
Block a user