From 34835697e889799e2b4e97c3bbf0ea73c04d5a64 Mon Sep 17 00:00:00 2001 From: Roman / Linnea Gräf Date: Fri, 21 Jul 2023 09:28:48 +0200 Subject: Use ursa-minor as API proxy (#762) * Use ursa-minor as API proxy * Allow setting a ursa server url * Make client aware of x-ursa-expires * Make profile data syncer work using legacy api * Add better header support * Add manual call functionality * Improve callUrsa to allow for raw strings * Save tokens better and add logs on http failure status codes * Remove API key requirement for PV * Make museum in pv also use ursa --- .../moulberry/notenoughupdates/NEUManager.java | 2 + .../options/customtypes/NEUDebugFlag.java | 4 +- .../options/seperateSections/ApiData.java | 9 + .../profileviewer/ProfileViewer.java | 10 +- .../profileviewer/SkyblockProfiles.java | 30 ++- .../moulberry/notenoughupdates/util/ApiUtil.java | 48 ++++- .../notenoughupdates/util/NEUDebugLogger.java | 3 +- .../notenoughupdates/util/ProfileApiSyncer.java | 15 +- .../moulberry/notenoughupdates/util/SBInfo.java | 2 +- .../commands/dev/DevTestCommand.kt | 16 +- .../commands/misc/ProfileViewerCommands.kt | 7 +- .../util/HttpStatusCodeException.kt | 28 +++ .../moulberry/notenoughupdates/util/UrsaClient.kt | 201 +++++++++++++++++++++ .../notenoughupdates/util/hypixelapi/Collection.kt | 2 +- .../notenoughupdates/util/kotlin/Coroutines.kt | 20 ++ 15 files changed, 351 insertions(+), 46 deletions(-) create mode 100644 src/main/kotlin/io/github/moulberry/notenoughupdates/util/HttpStatusCodeException.kt create mode 100644 src/main/kotlin/io/github/moulberry/notenoughupdates/util/UrsaClient.kt diff --git a/src/main/java/io/github/moulberry/notenoughupdates/NEUManager.java b/src/main/java/io/github/moulberry/notenoughupdates/NEUManager.java index cbc3ce3b..c60e7317 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/NEUManager.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/NEUManager.java @@ -39,6 +39,7 @@ import io.github.moulberry.notenoughupdates.util.ApiUtil; import io.github.moulberry.notenoughupdates.util.Constants; import io.github.moulberry.notenoughupdates.util.ItemResolutionQuery; import io.github.moulberry.notenoughupdates.util.ItemUtils; +import io.github.moulberry.notenoughupdates.util.UrsaClient; import io.github.moulberry.notenoughupdates.util.Utils; import net.minecraft.client.Minecraft; import net.minecraft.client.settings.KeyBinding; @@ -130,6 +131,7 @@ public class NEUManager { public long viewItemAttemptTime = 0; public final ApiUtil apiUtils = new ApiUtil(); + public final UrsaClient ursaClient = new UrsaClient(apiUtils); private final Map itemstackCache = new HashMap<>(); diff --git a/src/main/java/io/github/moulberry/notenoughupdates/options/customtypes/NEUDebugFlag.java b/src/main/java/io/github/moulberry/notenoughupdates/options/customtypes/NEUDebugFlag.java index ab1a6516..ba638b9b 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/options/customtypes/NEUDebugFlag.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/options/customtypes/NEUDebugFlag.java @@ -19,7 +19,9 @@ package io.github.moulberry.notenoughupdates.options.customtypes; +import io.github.moulberry.notenoughupdates.util.MinecraftExecutor; import io.github.moulberry.notenoughupdates.util.NEUDebugLogger; +import net.minecraft.client.Minecraft; import java.util.Arrays; import java.util.List; @@ -46,7 +48,7 @@ public enum NEUDebugFlag { } public void log(String message) { - NEUDebugLogger.log(this, message); + NEUDebugLogger.log(this, message); } public boolean isSet() { diff --git a/src/main/java/io/github/moulberry/notenoughupdates/options/seperateSections/ApiData.java b/src/main/java/io/github/moulberry/notenoughupdates/options/seperateSections/ApiData.java index 6d93b98d..ef871b83 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/options/seperateSections/ApiData.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/options/seperateSections/ApiData.java @@ -127,6 +127,15 @@ public class ApiData { @ConfigEditorText public String moulberryCodesApi = "moulberry.codes"; + + @Expose + @ConfigOption( + name = "Ursa Minor Proxy", + desc = "§4Do §lNOT §r§4change this, unless you know exactly what you are doing" + ) + @ConfigEditorText + public String ursaApi = "https://ursa.notenoughupdates.org/"; + public String getCommitApiUrl() { return String.format("https://api.github.com/repos/%s/%s/commits/%s", repoUser, repoName, repoBranch); } diff --git a/src/main/java/io/github/moulberry/notenoughupdates/profileviewer/ProfileViewer.java b/src/main/java/io/github/moulberry/notenoughupdates/profileviewer/ProfileViewer.java index a81956f2..ed3cf8ef 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/profileviewer/ProfileViewer.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/profileviewer/ProfileViewer.java @@ -22,6 +22,7 @@ package io.github.moulberry.notenoughupdates.profileviewer; import com.google.gson.JsonObject; import io.github.moulberry.notenoughupdates.NEUManager; import io.github.moulberry.notenoughupdates.NotEnoughUpdates; +import io.github.moulberry.notenoughupdates.util.UrsaClient; import io.github.moulberry.notenoughupdates.util.Utils; import lombok.Getter; import net.minecraft.init.Blocks; @@ -474,7 +475,7 @@ public class ProfileViewer { updatingResourceCollection.set(true); NotEnoughUpdates.INSTANCE.manager.apiUtils - .newHypixelApiRequest("resources/skyblock/collections") + .newAnonymousHypixelApiRequest("resources/skyblock/collections") .requestJson() .thenAccept(jsonObject -> { updatingResourceCollection.set(false); @@ -528,11 +529,8 @@ public class ProfileViewer { callback.accept(null); } else { if (!uuidToHypixelProfile.containsKey(uuid)) { - manager.apiUtils - .newHypixelApiRequest("player") - .queryArgument("uuid", uuid) - .maxCacheAge(Duration.ofSeconds(30)) - .requestJson() + manager.ursaClient + .get(UrsaClient.player(Utils.parseDashlessUUID(uuid))) .thenAccept(playerJson -> { if ( playerJson != null && diff --git a/src/main/java/io/github/moulberry/notenoughupdates/profileviewer/SkyblockProfiles.java b/src/main/java/io/github/moulberry/notenoughupdates/profileviewer/SkyblockProfiles.java index 88251d32..61872980 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/profileviewer/SkyblockProfiles.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/profileviewer/SkyblockProfiles.java @@ -28,6 +28,7 @@ import io.github.moulberry.notenoughupdates.profileviewer.bestiary.BestiaryData; import io.github.moulberry.notenoughupdates.profileviewer.weight.senither.SenitherWeight; import io.github.moulberry.notenoughupdates.profileviewer.weight.weight.Weight; import io.github.moulberry.notenoughupdates.util.Constants; +import io.github.moulberry.notenoughupdates.util.UrsaClient; import io.github.moulberry.notenoughupdates.util.Utils; import io.github.moulberry.notenoughupdates.util.hypixelapi.ProfileCollectionInfo; import lombok.Getter; @@ -84,6 +85,7 @@ public class SkyblockProfiles { "social" ); private final ProfileViewer profileViewer; + // TODO: replace with UUID type private final String uuid; private final AtomicBoolean updatingSkyblockProfilesState = new AtomicBoolean(false); private final AtomicBoolean updatingGuildInfoState = new AtomicBoolean(false); @@ -119,10 +121,8 @@ public class SkyblockProfiles { lastStatusInfoState = currentTime; updatingPlayerStatusState.set(true); - profileViewer.getManager().apiUtils - .newHypixelApiRequest("status") - .queryArgument("uuid", uuid) - .requestJson() + profileViewer.getManager().ursaClient + .get(UrsaClient.status(Utils.parseDashlessUUID(uuid))) .handle((jsonObject, ex) -> { updatingPlayerStatusState.set(false); @@ -143,10 +143,8 @@ public class SkyblockProfiles { lastBingoInfoState = currentTime; updatingBingoInfo.set(true); - NotEnoughUpdates.INSTANCE.manager.apiUtils - .newHypixelApiRequest("skyblock/bingo") - .queryArgument("uuid", uuid) - .requestJson() + NotEnoughUpdates.INSTANCE.manager.ursaClient + .get(UrsaClient.bingo(Utils.parseDashlessUUID(uuid))) .handle(((jsonObject, throwable) -> { updatingBingoInfo.set(false); @@ -317,10 +315,8 @@ public class SkyblockProfiles { lastPlayerInfoState = currentTime; updatingSkyblockProfilesState.set(true); - profileViewer.getManager().apiUtils - .newHypixelApiRequest("skyblock/profiles") - .queryArgument("uuid", uuid) - .requestJson() + profileViewer.getManager().ursaClient + .get(UrsaClient.profiles(Utils.parseDashlessUUID(uuid))) .handle((profilesJson, throwable) -> { if (profilesJson != null && profilesJson.has("success") && profilesJson.get("success").getAsBoolean() && profilesJson.has("profiles")) { @@ -388,10 +384,8 @@ public class SkyblockProfiles { lastGuildInfoState = currentTime; updatingGuildInfoState.set(true); - profileViewer.getManager().apiUtils - .newHypixelApiRequest("guild") - .queryArgument("player", uuid) - .requestJson() + profileViewer.getManager().ursaClient + .get(UrsaClient.guild(Utils.parseDashlessUUID(uuid))) .handle((jsonObject, ex) -> { updatingGuildInfoState.set(false); @@ -649,9 +643,7 @@ public class SkyblockProfiles { updatingMuseumData.set(true); String profileId = getOuterProfileJson().get("profile_id").getAsString(); - profileViewer.getManager().apiUtils.newHypixelApiRequest("skyblock/museum") - .queryArgument("profile", profileId) - .requestJson() + profileViewer.getManager().ursaClient.get(UrsaClient.museumForProfile(profileId)) .handle((museumJson, throwable) -> { if (museumJson != null && museumJson.has("success") && museumJson.get("success").getAsBoolean() && museumJson.has("members")) { diff --git a/src/main/java/io/github/moulberry/notenoughupdates/util/ApiUtil.java b/src/main/java/io/github/moulberry/notenoughupdates/util/ApiUtil.java index 4cb1abc8..3b6aed84 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/util/ApiUtil.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/util/ApiUtil.java @@ -58,10 +58,12 @@ import java.util.Collection; import java.util.Comparator; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.stream.Collectors; import java.util.zip.GZIPInputStream; public class ApiUtil { @@ -90,16 +92,17 @@ public class ApiUtil { try { KeyStore letsEncryptStore = KeyStore.getInstance("JKS"); letsEncryptStore.load(ApiUtil.class.getResourceAsStream("/neukeystore.jks"), "neuneu".toCharArray()); - ctx = SSLContext.getInstance("TLS"); KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); kmf.init(letsEncryptStore, null); tmf.init(letsEncryptStore); + ctx = SSLContext.getInstance("TLS"); ctx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); } catch (KeyStoreException | NoSuchAlgorithmException | KeyManagementException | UnrecoverableKeyException | IOException | CertificateException e) { System.out.println("Failed to load NEU keystore. A lot of API requests won't work"); e.printStackTrace(); + ctx = null; } } @@ -129,6 +132,8 @@ public class ApiUtil { private Duration maxCacheAge = Duration.ofSeconds(500); private String method = "GET"; private String postData = null; + private final Map headers = new HashMap<>(); + private Map> responseHeaders = new HashMap<>(); private String postContentType = null; public Request method(String method) { @@ -136,6 +141,11 @@ public class ApiUtil { return this; } + public Request header(String key, String value) { + this.headers.put(key, value); + return this; + } + /** * Specify a cache timeout of {@code null} to signify an uncacheable request. * Non {@code GET} requests are always uncacheable. @@ -196,9 +206,17 @@ public class ApiUtil { return new ApiCache.CacheKey(baseUrl, queryArguments, shouldGunzip); } + /** + * Note: This map may be empty on cache hits. + */ + public Map> getResponseHeaders() { + return responseHeaders; + } + private CompletableFuture requestString0() { return buildUrl().thenApplyAsync(url -> { InputStream inputStream = null; + int httpStatusCode = 200; URLConnection conn = null; try { try { @@ -212,6 +230,9 @@ public class ApiUtil { conn.setConnectTimeout(10000); conn.setReadTimeout(10000); conn.setRequestProperty("User-Agent", getUserAgent()); + for (Map.Entry header : headers.entrySet()) { + conn.setRequestProperty(header.getKey(), header.getValue()); + } if (this.postContentType != null) { conn.setRequestProperty("Content-Type", this.postContentType); } @@ -228,16 +249,33 @@ public class ApiUtil { } } - inputStream = conn.getInputStream(); + if (conn instanceof HttpURLConnection) { + HttpURLConnection httpConn = (HttpURLConnection) conn; + httpStatusCode = httpConn.getResponseCode(); + if (httpStatusCode >= 400) { + inputStream = httpConn.getErrorStream(); + } + } + if (inputStream == null) + inputStream = conn.getInputStream(); if (shouldGunzip || "gzip".equals(conn.getContentEncoding())) { inputStream = new GZIPInputStream(inputStream); } + responseHeaders = conn.getHeaderFields().entrySet().stream() + .collect(Collectors.toMap( + it -> it.getKey() == null ? null : it.getKey().toLowerCase(Locale.ROOT), + it -> new ArrayList<>(it.getValue()) + )); // While the assumption of UTF8 isn't always true; it *should* always be true. // Not in the sense that this will hold in most cases (although that as well), // but in the sense that any violation of this better have a good reason. - return IOUtils.toString(inputStream, StandardCharsets.UTF_8); + String response = IOUtils.toString(inputStream, StandardCharsets.UTF_8); + if (httpStatusCode >= 400) { + throw new HttpStatusCodeException(url, httpStatusCode, response); + } + return response; } finally { try { if (inputStream != null) { @@ -298,13 +336,15 @@ public class ApiUtil { } public static void patchHttpsRequest(HttpsURLConnection connection) { - connection.setSSLSocketFactory(ctx.getSocketFactory()); + if (ctx != null && connection != null) + connection.setSSLSocketFactory(ctx.getSocketFactory()); } public Request request() { return new Request(); } + @Deprecated public Request newHypixelApiRequest(String apiPath) { return newAnonymousHypixelApiRequest(apiPath) .queryArgument("key", NotEnoughUpdates.INSTANCE.config.apiData.apiKey); diff --git a/src/main/java/io/github/moulberry/notenoughupdates/util/NEUDebugLogger.java b/src/main/java/io/github/moulberry/notenoughupdates/util/NEUDebugLogger.java index e5d00a66..d73a500c 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/util/NEUDebugLogger.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/util/NEUDebugLogger.java @@ -22,7 +22,6 @@ package io.github.moulberry.notenoughupdates.util; import io.github.moulberry.notenoughupdates.NotEnoughUpdates; import io.github.moulberry.notenoughupdates.options.customtypes.NEUDebugFlag; import net.minecraft.client.Minecraft; -import net.minecraft.util.ChatComponentText; import net.minecraft.util.EnumChatFormatting; import java.util.function.Consumer; @@ -34,7 +33,7 @@ public class NEUDebugLogger { public static boolean allFlagsEnabled = false; private static void chatLogger(String message) { - Utils.addChatMessage(EnumChatFormatting.YELLOW + "[NEU DEBUG] " + message); + mc.addScheduledTask(() -> Utils.addChatMessage(EnumChatFormatting.YELLOW + "[NEU DEBUG] " + message)); } public static boolean isFlagEnabled(NEUDebugFlag flag) { diff --git a/src/main/java/io/github/moulberry/notenoughupdates/util/ProfileApiSyncer.java b/src/main/java/io/github/moulberry/notenoughupdates/util/ProfileApiSyncer.java index 67ed7c7b..ed88a6a5 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/util/ProfileApiSyncer.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/util/ProfileApiSyncer.java @@ -19,6 +19,7 @@ package io.github.moulberry.notenoughupdates.util; +import com.google.gson.JsonObject; import io.github.moulberry.notenoughupdates.NotEnoughUpdates; import io.github.moulberry.notenoughupdates.profileviewer.SkyblockProfiles; import net.minecraft.client.Minecraft; @@ -89,9 +90,15 @@ public class ProfileApiSyncer { if (Minecraft.getMinecraft().thePlayer == null) return; String uuid = Minecraft.getMinecraft().thePlayer.getUniqueID().toString().replace("-", ""); - NotEnoughUpdates.profileViewer.getOrLoadSkyblockProfiles(uuid, (profile) -> { - for (Consumer c : finishSyncCallbacks.values()) c.accept(profile); - finishSyncCallbacks.clear(); - }); + NotEnoughUpdates.INSTANCE.manager.apiUtils + .newHypixelApiRequest("/skyblock/profiles") + .queryArgument("uuid", uuid) + .requestJson() + .thenAcceptAsync((profile) -> { + SkyblockProfiles skyblockProfiles = new SkyblockProfiles(NotEnoughUpdates.profileViewer, uuid); + for (Consumer c : finishSyncCallbacks.values()) + c.accept((skyblockProfiles)); + finishSyncCallbacks.clear(); + }, MinecraftExecutor.OnThread); } } diff --git a/src/main/java/io/github/moulberry/notenoughupdates/util/SBInfo.java b/src/main/java/io/github/moulberry/notenoughupdates/util/SBInfo.java index 2d637ab8..e5a0e1b8 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/util/SBInfo.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/util/SBInfo.java @@ -459,7 +459,7 @@ public class SBInfo { public void updateMayor() { NotEnoughUpdates.INSTANCE.manager.apiUtils - .newHypixelApiRequest("resources/skyblock/election") + .newAnonymousHypixelApiRequest("resources/skyblock/election") .requestJson() .thenAccept(newJson -> mayorJson = newJson); } diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/dev/DevTestCommand.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/dev/DevTestCommand.kt index e72a1ed4..8ad765eb 100644 --- a/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/dev/DevTestCommand.kt +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/dev/DevTestCommand.kt @@ -19,7 +19,9 @@ package io.github.moulberry.notenoughupdates.commands.dev +import com.google.gson.JsonObject import com.mojang.brigadier.arguments.StringArgumentType +import com.mojang.brigadier.arguments.StringArgumentType.string import io.github.moulberry.notenoughupdates.BuildFlags import io.github.moulberry.notenoughupdates.NotEnoughUpdates import io.github.moulberry.notenoughupdates.autosubscribe.NEUAutoSubscribe @@ -36,7 +38,6 @@ import io.github.moulberry.notenoughupdates.util.brigadier.* import net.minecraft.client.Minecraft import net.minecraft.client.gui.GuiScreen import net.minecraft.command.ICommandSender -import net.minecraft.entity.item.EntityArmorStand import net.minecraft.entity.player.EntityPlayer import net.minecraft.launchwrapper.Launch import net.minecraft.util.ChatComponentText @@ -200,6 +201,16 @@ class DevTestCommand { } } } + thenLiteral("callUrsa") { + thenArgument("path", RestArgumentType) { path -> + thenExecute { + NotEnoughUpdates.INSTANCE.manager.ursaClient.getString(this[path]) + .thenAccept { + reply(it.toString()) + } + } + }.withHelp("Send an authenticated request to the current ursa server") + } thenLiteralExecute("dev") { NotEnoughUpdates.INSTANCE.config.hidden.dev = !NotEnoughUpdates.INSTANCE.config.hidden.dev reply("Dev mode " + if (NotEnoughUpdates.INSTANCE.config.hidden.dev) "§aenabled" else "§cdisabled") @@ -210,7 +221,8 @@ class DevTestCommand { }.withHelp("Force sync the config to disk") thenLiteralExecute("clearapicache") { ApiCache.clear() - reply("Cleared API cache") + NotEnoughUpdates.INSTANCE.manager.ursaClient.clearToken() + reply("Cleared API cache and reset ursa token") }.withHelp("Clear the API cache") thenLiteralExecute("searchmode") { NotEnoughUpdates.INSTANCE.config.hidden.firstTimeSearchFocus = true diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/misc/ProfileViewerCommands.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/misc/ProfileViewerCommands.kt index d25e880b..fac673b4 100644 --- a/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/misc/ProfileViewerCommands.kt +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/misc/ProfileViewerCommands.kt @@ -45,16 +45,11 @@ class ProfileViewerCommands { reply("${RED}Some parts of the profile viewer do not work with OptiFine Fast Render. Go to ESC > Options > Video Settings > Performance > Fast Render to disable it.") } - if (NotEnoughUpdates.INSTANCE.config.apiData.apiKey.isNullOrBlank()) { - reply("${RED}Can't view profile, an API key is not set. Run /api new and put the result in settings.") - return - } - NotEnoughUpdates.profileViewer.loadPlayerByName( name ?: Minecraft.getMinecraft().thePlayer.name ) { profile -> if (profile == null) { - reply("${RED}Invalid player name/API key. Maybe the API is down? Try /api new.") + reply("${RED}Invalid player name. Maybe the API is down? Try again later.") } else { profile.resetCache() ProfileViewerUtils.saveSearch(name) diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/util/HttpStatusCodeException.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/HttpStatusCodeException.kt new file mode 100644 index 00000000..bd38706f --- /dev/null +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/HttpStatusCodeException.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2023 NotEnoughUpdates contributors + * + * This file is part of NotEnoughUpdates. + * + * NotEnoughUpdates is free software: you can redistribute it + * and/or modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * NotEnoughUpdates 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with NotEnoughUpdates. If not, see . + */ + +package io.github.moulberry.notenoughupdates.util + +import java.net.URL + +data class HttpStatusCodeException( + val url: URL, + val statusCode: Int, + val serverMessage: String, +) : RuntimeException("Server returned HTTP Status Code: $statusCode:\n$serverMessage") diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/util/UrsaClient.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/UrsaClient.kt new file mode 100644 index 00000000..2320163d --- /dev/null +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/UrsaClient.kt @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2023 NotEnoughUpdates contributors + * + * This file is part of NotEnoughUpdates. + * + * NotEnoughUpdates is free software: you can redistribute it + * and/or modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * NotEnoughUpdates 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with NotEnoughUpdates. If not, see . + */ + +package io.github.moulberry.notenoughupdates.util + +import com.google.gson.JsonObject +import io.github.moulberry.notenoughupdates.NotEnoughUpdates +import io.github.moulberry.notenoughupdates.autosubscribe.NEUAutoSubscribe +import io.github.moulberry.notenoughupdates.options.customtypes.NEUDebugFlag +import io.github.moulberry.notenoughupdates.util.kotlin.Coroutines.await +import io.github.moulberry.notenoughupdates.util.kotlin.Coroutines.continueOn +import io.github.moulberry.notenoughupdates.util.kotlin.Coroutines.launchCoroutine +import net.minecraft.client.Minecraft +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent +import net.minecraftforge.fml.common.gameevent.TickEvent +import java.time.Duration +import java.time.Instant +import java.util.* +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentLinkedQueue + +class UrsaClient(val apiUtil: ApiUtil) { + private data class Token( + val validUntil: Instant, + val token: String, + val obtainedFrom: String, + ) { + val isValid get() = Instant.now().plusSeconds(60) < validUntil + } + + val logger = NEUDebugFlag.API_CACHE + + // Needs synchronized access + private var token: Token? = null + private var isPollingForToken = false + + private data class Request( + val path: String, + val objectMapping: Class?, + val consumer: CompletableFuture, + ) + + private val queue = ConcurrentLinkedQueue>() + private val ursaRoot + get() = NotEnoughUpdates.INSTANCE.config.apiData.ursaApi.removeSuffix("/").takeIf { it.isNotBlank() } + ?: "https://ursa.notenoughupdates.org" + + private suspend fun authorizeRequest(usedUrsaRoot: String, connection: ApiUtil.Request, t: Token?) { + if (t != null && t.obtainedFrom == usedUrsaRoot) { + logger.log("Authorizing request using token") + connection.header("x-ursa-token", t.token) + } else { + logger.log("Authorizing request using username and serverId") + val serverId = UUID.randomUUID().toString() + val session = Minecraft.getMinecraft().session + val name = session.username + connection.header("x-ursa-username", name).header("x-ursa-serverid", serverId) + continueOn(MinecraftExecutor.OffThread) + Minecraft.getMinecraft().sessionService.joinServer(session.profile, session.token, serverId) + logger.log("Authorizing request using username and serverId complete") + } + } + + private suspend fun saveToken(usedUrsaRoot: String, connection: ApiUtil.Request) { + logger.log("Attempting to save token") + val token = + connection.responseHeaders["x-ursa-token"]?.firstOrNull() + val validUntil = connection.responseHeaders["x-ursa-expires"] + ?.firstOrNull() + ?.toLongOrNull() + ?.let { Instant.ofEpochMilli(it) } ?: (Instant.now() + Duration.ofMinutes(55)) + continueOn(MinecraftExecutor.OnThread) + if (token == null) { + isPollingForToken = false + logger.log("No token found. Marking as non polling") + } else { + this.token = Token(validUntil, token, usedUrsaRoot) + isPollingForToken = false + logger.log("Token saving successful") + } + } + + private suspend fun performRequest(request: Request, token: Token?) { + val usedUrsaRoot = ursaRoot + val apiRequest = apiUtil.request().url("$usedUrsaRoot/${request.path}") + try { + logger.log("Ursa Request started") + authorizeRequest(usedUrsaRoot, apiRequest, token) + val response = + if (request.objectMapping == null) + (apiRequest.requestString().await() as T) + else + (apiRequest.requestJson(request.objectMapping).await() as T) + logger.log("Request completed") + saveToken(usedUrsaRoot, apiRequest) + request.consumer.complete(response) + } catch (e: Exception) { + e.printStackTrace() + logger.log("Request failed") + continueOn(MinecraftExecutor.OnThread) + isPollingForToken = false + request.consumer.completeExceptionally(e) + } + } + + private fun bumpRequests() { + while (!queue.isEmpty()) { + if (isPollingForToken) return + val nextRequest = queue.poll() + if (nextRequest == null) { + logger.log("No request to bump found") + return + } + logger.log("Request found") + var t = token + if (!(t != null && t.isValid && t.obtainedFrom == ursaRoot)) { + isPollingForToken = true + t = null + if (token != null) { + logger.log("Disposing old invalid ursa token.") + token = null + } + logger.log("No token saved. Marking this request as a token poll request") + } + launchCoroutine { performRequest(nextRequest, t) } + } + } + + + fun clearToken() { + synchronized(this) { + token = null + } + } + + fun get(path: String, clazz: Class): CompletableFuture { + val c = CompletableFuture() + queue.add(Request(path, clazz, c)) + return c + } + + + fun getString(path: String): CompletableFuture { + val c = CompletableFuture() + queue.add(Request(path, null, c)) + return c + } + + fun get(knownRequest: KnownRequest): CompletableFuture { + return get(knownRequest.path, knownRequest.type) + } + + data class KnownRequest(val path: String, val type: Class) { + fun typed(newType: Class) = KnownRequest(path, newType) + inline fun typed() = typed(N::class.java) + } + + @NEUAutoSubscribe + object TickHandler { + @SubscribeEvent + fun onTick(event: TickEvent) { + NotEnoughUpdates.INSTANCE.manager.ursaClient.bumpRequests() + } + } + + companion object { + @JvmStatic + fun profiles(uuid: UUID) = KnownRequest("v1/hypixel/profiles/${uuid}", JsonObject::class.java) + + @JvmStatic + fun player(uuid: UUID) = KnownRequest("v1/hypixel/player/${uuid}", JsonObject::class.java) + + @JvmStatic + fun guild(uuid: UUID) = KnownRequest("v1/hypixel/guild/${uuid}", JsonObject::class.java) + + @JvmStatic + fun bingo(uuid: UUID) = KnownRequest("v1/hypixel/bingo/${uuid}", JsonObject::class.java) + + @JvmStatic + fun museumForProfile(profileUuid: String) = KnownRequest("v1/hypixel/museum/${profileUuid}", JsonObject::class.java) + + @JvmStatic + fun status(uuid: UUID) = KnownRequest("v1/hypixel/status/${uuid}", JsonObject::class.java) + } +} diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/util/hypixelapi/Collection.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/hypixelapi/Collection.kt index b2c7fcec..e5c7263c 100644 --- a/src/main/kotlin/io/github/moulberry/notenoughupdates/util/hypixelapi/Collection.kt +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/hypixelapi/Collection.kt @@ -95,7 +95,7 @@ data class ProfileCollectionInfo( val hypixelCollectionInfo: CompletableFuture by lazy { NotEnoughUpdates.INSTANCE.manager.apiUtils - .newHypixelApiRequest("resources/skyblock/collections") + .newAnonymousHypixelApiRequest("resources/skyblock/collections") .requestJson() .thenApply { NotEnoughUpdates.INSTANCE.manager.gson.fromJson(it, CollectionMetadata::class.java) diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/util/kotlin/Coroutines.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/kotlin/Coroutines.kt index 4bcf0c61..d84bf082 100644 --- a/src/main/kotlin/io/github/moulberry/notenoughupdates/util/kotlin/Coroutines.kt +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/kotlin/Coroutines.kt @@ -24,6 +24,7 @@ import net.minecraftforge.fml.common.eventhandler.SubscribeEvent import net.minecraftforge.fml.common.gameevent.TickEvent import java.util.concurrent.CompletableFuture import java.util.concurrent.Executor +import java.util.concurrent.ForkJoinPool import kotlin.coroutines.* @NEUAutoSubscribe @@ -54,6 +55,13 @@ object Coroutines { } } + fun launchCoroutine(block: suspend () -> T): CompletableFuture { + return launchCoroutineOnCurrentThread { + continueOn(ForkJoinPool.commonPool()) + block() + } + } + private data class DelayedTask(val contination: () -> Unit, var tickDelay: Int) private val tasks = mutableListOf() @@ -76,6 +84,18 @@ object Coroutines { } } + + suspend fun CompletableFuture.await(): T { + return suspendCoroutine { cont -> + handle { res, ex -> + if (ex != null) + cont.resumeWithException(ex) + else + cont.resume(res) + } + } + } + suspend fun waitTicks(tickCount: Int) { suspendCoroutine { synchronized(tasks) { -- cgit