diff --git a/build.gradle b/build.gradle index 8e1540b..d986858 100644 --- a/build.gradle +++ b/build.gradle @@ -9,5 +9,21 @@ plugins { id 'org.spongepowered.mixin' version '0.7-SNAPSHOT' apply false - id 'me.modmuss50.mod-publish-plugin' version '0.6.3' apply false + id 'me.modmuss50.mod-publish-plugin' version '0.8.1' apply false +} + +allprojects { + repositories { + exclusiveContent { + forRepository { + maven { + name = 'MineSkin' + url = 'https://repo.inventivetalent.org/repository/public' + } + } + filter { + includeGroupAndSubgroups('org.mineskin') + } + } + } } diff --git a/buildSrc/src/main/groovy/multiloader-publish.gradle b/buildSrc/src/main/groovy/multiloader-publish.gradle index 8648805..f759713 100644 --- a/buildSrc/src/main/groovy/multiloader-publish.gradle +++ b/buildSrc/src/main/groovy/multiloader-publish.gradle @@ -6,7 +6,7 @@ publishMods { if (project.name == 'fabric') file = remapJar.archiveFile else - file = jar.archiveFile + file = tasks.named('jarJar').get().archiveFile modLoaders.add(project.name) type = STABLE diff --git a/common/build.gradle b/common/build.gradle index 0a4bef1..c168537 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -11,6 +11,8 @@ dependencies { parchment("org.parchmentmc.data:parchment-${parchment_minecraft}:${parchment_version}@zip") } compileOnly group: 'org.spongepowered', name: 'mixin', version: '0.8.5' + + implementation("org.mineskin:java-client:${mineskin_client_version}") } loom { diff --git a/common/src/main/java/net/lionarius/skinrestorer/mineskin/Java11RequestHandler.java b/common/src/main/java/net/lionarius/skinrestorer/mineskin/Java11RequestHandler.java new file mode 100644 index 0000000..b69832a --- /dev/null +++ b/common/src/main/java/net/lionarius/skinrestorer/mineskin/Java11RequestHandler.java @@ -0,0 +1,187 @@ +package net.lionarius.skinrestorer.mineskin; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import org.mineskin.MineSkinClientImpl; +import org.mineskin.data.CodeAndMessage; +import org.mineskin.exception.MineSkinRequestException; +import org.mineskin.exception.MineskinException; +import org.mineskin.request.RequestHandler; +import org.mineskin.response.MineSkinResponse; +import org.mineskin.response.ResponseConstructor; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.Map; +import java.util.logging.Level; +import java.util.stream.Collectors; + +// copy-pasted from https://github.com/InventivetalentDev/MineskinClient/blob/master/java11/src/main/java/org/mineskin/Java11RequestHandler.java +// with some modifications to support proxy +public class Java11RequestHandler extends RequestHandler { + + private final Gson gson; + private final HttpClient httpClient; + + public Java11RequestHandler(String userAgent, String apiKey, int timeout, Gson gson, InetSocketAddress proxy) { + super(userAgent, apiKey, timeout, gson); + this.gson = gson; + + HttpClient.Builder clientBuilder = HttpClient.newBuilder() + .connectTimeout(java.time.Duration.ofMillis(timeout)); + + if (userAgent != null) { + clientBuilder.followRedirects(HttpClient.Redirect.NORMAL); + } + + if (proxy != null) { + clientBuilder.proxy(ProxySelector.of(proxy)); + } + + this.httpClient = clientBuilder.build(); + } + + private > R wrapResponse(HttpResponse response, Class clazz, ResponseConstructor constructor) + throws IOException { + String rawBody = response.body(); + try { + JsonObject jsonBody = gson.fromJson(rawBody, JsonObject.class); + R wrapped = constructor.construct( + response.statusCode(), + lowercaseHeaders(response.headers().map()), + jsonBody, + gson, clazz + ); + if (!wrapped.isSuccess()) { + throw new MineSkinRequestException( + wrapped.getFirstError().map(CodeAndMessage::code).orElse("request_failed"), + wrapped.getFirstError().map(CodeAndMessage::message).orElse("Request Failed"), + wrapped + ); + } + return wrapped; + } catch (JsonParseException e) { + MineSkinClientImpl.LOGGER.log(Level.WARNING, "Failed to parse response body: " + rawBody, e); + throw new MineskinException("Failed to parse response", e); + } + } + + private Map lowercaseHeaders(Map> headers) { + return headers.entrySet().stream() + .collect(Collectors.toMap( + entry -> entry.getKey().toLowerCase(), + entry -> String.join(", ", entry.getValue()) + )); + } + + public > R getJson(String url, Class clazz, ResponseConstructor constructor) + throws IOException { + MineSkinClientImpl.LOGGER.fine("GET " + url); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .header("User-Agent", this.userAgent); + HttpRequest request; + if (apiKey != null) { + request = requestBuilder + .header("Authorization", "Bearer " + apiKey) + .header("Accept", "application/json").build(); + } else { + request = requestBuilder.build(); + } + HttpResponse response; + try { + response = this.httpClient.send(request, BodyHandlers.ofString()); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return wrapResponse(response, clazz, constructor); + } + + public > R postJson(String url, JsonObject data, Class clazz, ResponseConstructor constructor) + throws IOException { + MineSkinClientImpl.LOGGER.fine("POST " + url); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .POST(BodyPublishers.ofString(gson.toJson(data))) + .header("Content-Type", "application/json") + .header("User-Agent", this.userAgent); + HttpRequest request; + if (apiKey != null) { + request = requestBuilder + .header("Authorization", "Bearer " + apiKey) + .header("Accept", "application/json").build(); + } else { + request = requestBuilder.build(); + } + + HttpResponse response; + try { + response = this.httpClient.send(request, BodyHandlers.ofString()); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return wrapResponse(response, clazz, constructor); + } + + public > R postFormDataFile(String url, String key, String filename, InputStream in, Map data, Class clazz, ResponseConstructor constructor) + throws IOException { + MineSkinClientImpl.LOGGER.fine("POST " + url); + + String boundary = "mineskin-" + System.currentTimeMillis(); + StringBuilder bodyBuilder = new StringBuilder(); + + // add form fields + for (Map.Entry entry : data.entrySet()) { + bodyBuilder.append("--").append(boundary).append("\r\n") + .append("Content-Disposition: form-data; name=\"").append(entry.getKey()).append("\"\r\n\r\n") + .append(entry.getValue()).append("\r\n"); + } + + // add file + byte[] fileContent = in.readAllBytes(); + bodyBuilder.append("--").append(boundary).append("\r\n") + .append("Content-Disposition: form-data; name=\"").append(key) + .append("\"; filename=\"").append(filename).append("\"\r\n") + .append("Content-Type: image/png\r\n\r\n"); + byte[] bodyStart = bodyBuilder.toString().getBytes(); + byte[] boundaryEnd = ("\r\n--" + boundary + "--\r\n").getBytes(); + byte[] bodyString = new byte[bodyStart.length + fileContent.length + boundaryEnd.length]; + System.arraycopy(bodyStart, 0, bodyString, 0, bodyStart.length); + System.arraycopy(fileContent, 0, bodyString, bodyStart.length, fileContent.length); + System.arraycopy(boundaryEnd, 0, bodyString, bodyStart.length + fileContent.length, boundaryEnd.length); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .POST(HttpRequest.BodyPublishers.ofByteArray(bodyString)) + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .header("User-Agent", this.userAgent); + HttpRequest request; + if (apiKey != null) { + request = requestBuilder + .header("Authorization", "Bearer " + apiKey) + .header("Accept", "application/json").build(); + } else { + request = requestBuilder.build(); + } + + HttpResponse response; + try { + response = this.httpClient.send(request, BodyHandlers.ofString()); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return wrapResponse(response, clazz, constructor); + } +} diff --git a/common/src/main/java/net/lionarius/skinrestorer/skin/provider/MineskinSkinProvider.java b/common/src/main/java/net/lionarius/skinrestorer/skin/provider/MineskinSkinProvider.java index f869678..bdb53d0 100644 --- a/common/src/main/java/net/lionarius/skinrestorer/skin/provider/MineskinSkinProvider.java +++ b/common/src/main/java/net/lionarius/skinrestorer/skin/provider/MineskinSkinProvider.java @@ -4,21 +4,26 @@ import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import com.google.common.util.concurrent.UncheckedExecutionException; -import com.google.gson.JsonObject; import com.mojang.authlib.properties.Property; import it.unimi.dsi.fastutil.Pair; import net.lionarius.skinrestorer.SkinRestorer; +import net.lionarius.skinrestorer.mineskin.Java11RequestHandler; import net.lionarius.skinrestorer.skin.SkinVariant; import net.lionarius.skinrestorer.util.JsonUtils; import net.lionarius.skinrestorer.util.PlayerUtils; import net.lionarius.skinrestorer.util.Result; import net.lionarius.skinrestorer.util.WebUtils; import org.jetbrains.annotations.NotNull; +import org.mineskin.MineSkinClient; +import org.mineskin.data.Variant; +import org.mineskin.data.Visibility; +import org.mineskin.request.GenerateRequest; +import org.mineskin.response.QueueResponse; -import java.io.IOException; +import java.net.InetSocketAddress; import java.net.URI; import java.net.URISyntaxException; -import java.net.http.HttpRequest; +import java.time.Duration; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -27,6 +32,7 @@ public final class MineskinSkinProvider implements SkinProvider { public static final String PROVIDER_NAME = "web"; private static final URI API_URI; + private static MineSkinClient MINESKIN_CLIENT; private static LoadingCache, Optional> SKIN_CACHE; @@ -39,6 +45,20 @@ public final class MineskinSkinProvider implements SkinProvider { } public static void reload() { + MINESKIN_CLIENT = MineSkinClient + .builder() + .userAgent(WebUtils.USER_AGENT) + .gson(JsonUtils.GSON) + .timeout((int) Duration.ofSeconds(SkinRestorer.getConfig().requestTimeout()).toMillis()) + .requestHandler((userAgent, apiKey, timeout, gson) -> new Java11RequestHandler( + userAgent, + apiKey, + timeout, + gson, + SkinRestorer.getConfig().proxy().map(proxy -> new InetSocketAddress(proxy.host(), proxy.port())).orElse(null) + )) + .build(); + createCache(); } @@ -80,29 +100,26 @@ public final class MineskinSkinProvider implements SkinProvider { } private static Optional loadSkin(URI uri, SkinVariant variant) throws Exception { - var result = MineskinSkinProvider.uploadToMineskin(uri, variant); - var texture = result.getAsJsonObject("data").getAsJsonObject("texture"); - var textures = new Property(PlayerUtils.TEXTURES_KEY, texture.get("value").getAsString(), texture.get("signature").getAsString()); + var mineskinVariant = switch (variant) { + case CLASSIC -> Variant.CLASSIC; + case SLIM -> Variant.SLIM; + }; - return Optional.of(textures); - } - - private static JsonObject uploadToMineskin(URI url, SkinVariant variant) throws IOException { - var body = ("{\"variant\":\"%s\",\"name\":\"%s\",\"visibility\":%d,\"url\":\"%s\"}") - .formatted(variant.toString(), "none", 0, url); + var request = GenerateRequest.url(uri) + .variant(mineskinVariant) + .name("skinrestorer-skin") + .visibility(Visibility.UNLISTED); - var request = HttpRequest.newBuilder() - .uri(MineskinSkinProvider.API_URI.resolve("/generate/url")) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .header("Content-Type", "application/json") - .build(); + var skin = MINESKIN_CLIENT.queue().submit(request) + .thenApply(QueueResponse::getJob) + .thenCompose(jobInfo -> jobInfo.waitForCompletion(MINESKIN_CLIENT)) + .thenCompose(jobReference -> jobReference.getOrLoadSkin(MINESKIN_CLIENT)) + .join(); - var response = WebUtils.executeRequest(request); - WebUtils.throwOnClientErrors(response); - - if (response.statusCode() != 200) - throw new IllegalArgumentException("could not get mineskin skin"); - - return JsonUtils.parseJson(response.body()); + return Optional.of(new Property( + PlayerUtils.TEXTURES_KEY, + skin.texture().data().value(), + skin.texture().data().signature() + )); } } diff --git a/common/src/main/java/net/lionarius/skinrestorer/util/JsonUtils.java b/common/src/main/java/net/lionarius/skinrestorer/util/JsonUtils.java index d648601..14aec21 100644 --- a/common/src/main/java/net/lionarius/skinrestorer/util/JsonUtils.java +++ b/common/src/main/java/net/lionarius/skinrestorer/util/JsonUtils.java @@ -17,7 +17,7 @@ import java.util.UUID; public final class JsonUtils { - private static final Gson GSON = new GsonBuilder() + public static final Gson GSON = new GsonBuilder() .registerTypeAdapter(UUID.class, new UUIDTypeAdapter()) .registerTypeAdapter(PropertyMap.class, new PropertyMap.Serializer()) .registerTypeAdapter(GameProfile.class, new GameProfile.Serializer()) diff --git a/fabric/build.gradle b/fabric/build.gradle index 2136a8d..62adc91 100644 --- a/fabric/build.gradle +++ b/fabric/build.gradle @@ -5,6 +5,7 @@ plugins { id 'multiloader-publish' } + dependencies { minecraft "com.mojang:minecraft:${minecraft_version}" mappings loom.layered { @@ -14,6 +15,8 @@ dependencies { modImplementation "net.fabricmc:fabric-loader:${fabric_loader_version}" modCompileOnly "net.fabricmc.fabric-api:fabric-api:${fabric_api_version}" + + include implementation("org.mineskin:java-client:${mineskin_client_version}") } loom { diff --git a/forge/build.gradle b/forge/build.gradle index 1624867..9211492 100644 --- a/forge/build.gradle +++ b/forge/build.gradle @@ -15,6 +15,14 @@ mixin { config "${mod_id}.mixins.json" } +jarJar.enable() + +tasks.named('jarJar') { + archiveClassifier = '' +} + +jar.finalizedBy('jarJar') + minecraft { mappings channel: 'parchment', version: "${parchment_version}-${parchment_minecraft}" @@ -60,6 +68,11 @@ dependencies { annotationProcessor('org.spongepowered:mixin:0.8.5-SNAPSHOT:processor') implementation('net.sf.jopt-simple:jopt-simple:5.0.4') { version { strictly '5.0.4' } } + + // TODO: somehow fix forge gradle including dependencies of java-client + minecraftLibrary(jarJar(group: 'org.mineskin', name: 'java-client', version: mineskin_client_version)) { + jarJar.ranged(it, "[${mineskin_client_version},)") + } } // for some reason mixin plugin does not add 'MixinConfigs' to MANIFEST.MF so we do it manually diff --git a/gradle.properties b/gradle.properties index 2eb6290..44eec92 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,6 +17,8 @@ license=MIT credits= description=A server-side mod for managing skins. +mineskin_client_version=3.0.1-SNAPSHOT + # ParchmentMC mappings, see https://parchmentmc.org/docs/getting-started#choose-a-version for new versions parchment_minecraft=1.21 parchment_version=2024.11.10 diff --git a/neoforge/build.gradle b/neoforge/build.gradle index 7082cf5..872c927 100644 --- a/neoforge/build.gradle +++ b/neoforge/build.gradle @@ -14,6 +14,26 @@ if (at.exists()) { minecraft.accessTransformers.file(at) } +jarJar.enable() + +tasks.named('jar') { + archiveClassifier = 'thin' +} + +tasks.named('jarJar') { + archiveClassifier = '' +} + +jar.finalizedBy('jarJar') + +dependencies { + implementation "net.neoforged:neoforge:${neoforge_version}" + + implementation(jarJar(group: 'org.mineskin', name: 'java-client', version: mineskin_client_version)) { + jarJar.ranged(it, "[${mineskin_client_version},)") + } +} + subsystems { parchment { minecraftVersion = parchment_minecraft @@ -36,7 +56,3 @@ runs { workingDirectory = file('../run/server') } } - -dependencies { - implementation "net.neoforged:neoforge:${neoforge_version}" -}