/* * 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 com.mojang.authlib.exceptions.AuthenticationException 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" fun hasNonStandardUrsa() = ursaRoot != "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 authenticationState = AuthenticationState.SUCCEEDED 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 if (e is AuthenticationException) { authenticationState = AuthenticationState.FAILED_TO_JOINSERVER } if (e is HttpStatusCodeException && e.statusCode == 401) { authenticationState = AuthenticationState.REJECTED this.token = null } 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() } } private var authenticationState = AuthenticationState.NOT_ATTEMPTED fun getAuthenticationState(): AuthenticationState { if (authenticationState == AuthenticationState.SUCCEEDED && token?.isValid != true) { return AuthenticationState.OUTDATED } return authenticationState } enum class AuthenticationState { NOT_ATTEMPTED, FAILED_TO_JOINSERVER, REJECTED, SUCCEEDED, OUTDATED, } companion object { @JvmStatic fun profiles(uuid: UUID) = KnownRequest("v1/hypixel/v2/profiles/${uuid}", JsonObject::class.java) @JvmStatic fun player(uuid: UUID) = KnownRequest("v1/hypixel/v2/player/${uuid}", JsonObject::class.java) @JvmStatic fun guild(uuid: UUID) = KnownRequest("v1/hypixel/v2/guild/${uuid}", JsonObject::class.java) @JvmStatic fun bingo(uuid: UUID) = KnownRequest("v1/hypixel/v2/bingo/${uuid}", JsonObject::class.java) @JvmStatic fun museumForProfile(profileUuid: String) = KnownRequest("v1/hypixel/v2/museum/${profileUuid}", JsonObject::class.java) @JvmStatic fun gardenForProfile(profileUuid: String) = KnownRequest("v1/hypixel/v2/garden/${profileUuid}", JsonObject::class.java) @JvmStatic fun status(uuid: UUID) = KnownRequest("v1/hypixel/v2/status/${uuid}", JsonObject::class.java) } }