feat(offline-skin): WIP: implement yggdrasil server to serve skin/cape textures.
This commit is contained in:
@@ -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()));
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user