diff --git a/http/build.gradle.kts b/http/build.gradle.kts new file mode 100644 index 0000000000..f433eed684 --- /dev/null +++ b/http/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + `maven-publish` + kotlin("jvm") +} + +dependencies { + api("com.google.inject:guice:${Versions.guice}") + + implementation("com.google.guava:guava:${Versions.guava}") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.kotlinCoroutines}") +} + +publishing { + publications.create("maven") { + from(components["java"]) + + pom { + packaging = "jar" + name.set("OpenRS2 HTTP") + description.set( + """ + Guava module for creating a HTTP client with .netrc and + redirection support. The I/O dispatcher is used to run + asynchronous requests, as code using coroutines will likely use + the I/O dispatcher to read the response body. + """.trimIndent() + ) + } + } +} diff --git a/http/src/main/kotlin/org/openrs2/http/HttpClientProvider.kt b/http/src/main/kotlin/org/openrs2/http/HttpClientProvider.kt new file mode 100644 index 0000000000..49b8959994 --- /dev/null +++ b/http/src/main/kotlin/org/openrs2/http/HttpClientProvider.kt @@ -0,0 +1,16 @@ +package org.openrs2.http + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import java.net.http.HttpClient +import javax.inject.Provider + +public class HttpClientProvider : Provider { + override fun get(): HttpClient { + return HttpClient.newBuilder() + .authenticator(NetrcAuthenticator.read()) + .executor(Dispatchers.IO.asExecutor()) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + } +} diff --git a/http/src/main/kotlin/org/openrs2/http/HttpModule.kt b/http/src/main/kotlin/org/openrs2/http/HttpModule.kt new file mode 100644 index 0000000000..649fff1e24 --- /dev/null +++ b/http/src/main/kotlin/org/openrs2/http/HttpModule.kt @@ -0,0 +1,13 @@ +package org.openrs2.http + +import com.google.inject.AbstractModule +import com.google.inject.Scopes +import java.net.http.HttpClient + +public object HttpModule : AbstractModule() { + override fun configure() { + bind(HttpClient::class.java) + .toProvider(HttpClientProvider::class.java) + .`in`(Scopes.SINGLETON) + } +} diff --git a/http/src/main/kotlin/org/openrs2/http/HttpResponseExtensions.kt b/http/src/main/kotlin/org/openrs2/http/HttpResponseExtensions.kt new file mode 100644 index 0000000000..bf34c2596c --- /dev/null +++ b/http/src/main/kotlin/org/openrs2/http/HttpResponseExtensions.kt @@ -0,0 +1,13 @@ +package org.openrs2.http + +import java.io.IOException +import java.net.http.HttpResponse + +private val DEFAULT_EXPECTED_STATUS_CODES = setOf(200) + +public fun HttpResponse.checkStatusCode(expectedStatusCodes: Set = DEFAULT_EXPECTED_STATUS_CODES) { + val status = statusCode() + if (status !in expectedStatusCodes) { + throw IOException("Unexpected status code: $status") + } +} diff --git a/http/src/main/kotlin/org/openrs2/http/NetrcAuthenticator.kt b/http/src/main/kotlin/org/openrs2/http/NetrcAuthenticator.kt new file mode 100644 index 0000000000..e07f6a14ac --- /dev/null +++ b/http/src/main/kotlin/org/openrs2/http/NetrcAuthenticator.kt @@ -0,0 +1,39 @@ +package org.openrs2.http + +import com.google.common.base.StandardSystemProperty +import java.io.IOException +import java.net.Authenticator +import java.net.PasswordAuthentication +import java.nio.file.Files +import java.nio.file.Paths +import java.util.Scanner + +public class NetrcAuthenticator( + private val hosts: Map, + private val default: PasswordAuthentication? +) : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication? { + return hosts.getOrDefault(requestingHost, default) + } + + public companion object { + private val EMPTY_AUTHENTICATOR = NetrcAuthenticator(emptyMap(), null) + + public fun read(): NetrcAuthenticator { + val home = StandardSystemProperty.USER_HOME.value() ?: throw IOException("user.home not defined") + val path = Paths.get(home, ".netrc") + + return if (Files.exists(path)) { + Scanner(path, Charsets.UTF_8).use { scanner -> + read(scanner) + } + } else { + EMPTY_AUTHENTICATOR + } + } + + public fun read(scanner: Scanner): NetrcAuthenticator { + return NetrcReader(scanner).read() + } + } +} diff --git a/http/src/main/kotlin/org/openrs2/http/NetrcReader.kt b/http/src/main/kotlin/org/openrs2/http/NetrcReader.kt new file mode 100644 index 0000000000..c73cd1b196 --- /dev/null +++ b/http/src/main/kotlin/org/openrs2/http/NetrcReader.kt @@ -0,0 +1,95 @@ +package org.openrs2.http + +import java.io.IOException +import java.net.PasswordAuthentication +import java.util.Scanner +import java.util.regex.Pattern + +public class NetrcReader(private val scanner: Scanner) { + private enum class State { + DEFAULT, + READ_MACHINE + } + + private val hosts = mutableMapOf() + private var default: PasswordAuthentication? = null + + private var host: String? = null + private var username: String? = null + private var password: String? = null + + private var state = State.DEFAULT + set(value) { + if (field != State.DEFAULT) { + val auth = PasswordAuthentication(username ?: "", password?.toCharArray() ?: EMPTY_CHAR_ARRAY) + + val host = host + if (host != null) { + hosts[host] = auth + } else if (default == null) { + default = auth + } + } + + field = value + } + + init { + scanner.useDelimiter(WHITESPACE) + } + + public fun read(): NetrcAuthenticator { + while (scanner.hasNext()) { + when (val token = scanner.next()) { + "machine" -> { + if (!scanner.hasNext()) { + throw IOException("Expected hostname") + } + + state = State.READ_MACHINE + host = scanner.next() + } + "default" -> { + state = State.READ_MACHINE + host = null + } + "login", "password", "account" -> { + if (state != State.READ_MACHINE) { + throw IOException("Unexpected token '$token'") + } else if (!scanner.hasNext()) { + throw IOException("Expected $token") + } + + if (token == "login") { + username = scanner.next() + } else if (token == "password") { + password = scanner.next() + } + } + "macdef" -> skipMacro() + } + } + + // trigger the logic to add the final machine to the map + state = State.DEFAULT + + return NetrcAuthenticator(hosts, default) + } + + private fun skipMacro() { + if (!scanner.hasNext()) { + throw IOException("Expected macro name") + } + + while (scanner.hasNextLine() && scanner.nextLine().isNotEmpty()) { + // empty + } + + state = State.DEFAULT + } + + private companion object { + private val WHITESPACE = Pattern.compile("\\s+") + private val EMPTY_CHAR_ARRAY = charArrayOf() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e90e2c0092..e06d43b478 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,6 +31,7 @@ include( "deob-processor", "deob-util", "game", + "http", "json", "net", "nonfree",