feat: basic api, captcha, and login flow
whew, that's a lot
This commit is contained in:
parent
946429a2f5
commit
a2fb59c6f8
25 changed files with 605 additions and 50 deletions
|
|
@ -19,13 +19,29 @@ kotlin {
|
|||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.settings)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.websockets)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
|
||||
implementation(libs.kermit)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring(libs.desugar)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "moe.lava.neon.core"
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
|
|
@ -46,6 +62,7 @@ android {
|
|||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package moe.lava.neon.core.api
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
@Suppress("ConstantLocale")
|
||||
internal actual val platformSuperProps = PlatformProps(
|
||||
device = android.os.Build.DEVICE,
|
||||
systemLocale = Locale.getDefault().language,
|
||||
osVersion = "${android.os.Build.VERSION.SDK_INT}",
|
||||
)
|
||||
|
|
@ -2,7 +2,6 @@ package moe.lava.neon.core
|
|||
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.russhwolf.settings.nullableString
|
||||
import com.russhwolf.settings.string
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
|
|
@ -12,5 +11,6 @@ import dev.zacsweers.metro.SingleIn
|
|||
class AppSettings {
|
||||
private val settings = Settings()
|
||||
|
||||
var fingerprint by settings.nullableString()
|
||||
var token by settings.nullableString()
|
||||
}
|
||||
|
|
|
|||
108
core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt
Normal file
108
core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package moe.lava.neon.core.api
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.HttpSend
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.cookies.HttpCookies
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.plugins.plugin
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.userAgent
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.util.appendAll
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNamingStrategy
|
||||
import moe.lava.neon.core.api.captcha.CaptchaRequest
|
||||
import moe.lava.neon.core.api.captcha.CaptchaResponse
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@Inject
|
||||
class ApiClient {
|
||||
private val logger = Logger.withTag("neon.core.api/client")
|
||||
|
||||
private var captchaHandler: (suspend (CaptchaRequest) -> CaptchaResponse)? = null
|
||||
|
||||
fun setCaptchaHandler(handler: suspend (CaptchaRequest) -> CaptchaResponse) {
|
||||
this.captchaHandler = handler
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
val client = HttpClient {
|
||||
expectSuccess = true
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
install(WebSockets)
|
||||
install(HttpCookies)
|
||||
defaultRequest {
|
||||
url("https://discord.com/api/v9/")
|
||||
userAgent(ApiConstants.userAgent)
|
||||
headers.appendAll(ApiConstants.baseHeaders)
|
||||
}
|
||||
}.apply {
|
||||
plugin(HttpSend).intercept { req ->
|
||||
logger.d { "Intercepting ${req.url.buildString()}" }
|
||||
val call = execute(req)
|
||||
if (call.response.status.value != 400) return@intercept call
|
||||
logger.d { "Found 400 response: ${call.response.bodyAsText()}" }
|
||||
val captchaRequest = runCatching { call.response.body<CaptchaRequest>() }
|
||||
.getOrNull()
|
||||
?: return@intercept call
|
||||
|
||||
logger.d { "Starting captcha flow for: $captchaRequest" }
|
||||
|
||||
val captcha = captchaHandler
|
||||
if (captcha == null) {
|
||||
logger.w { "Captcha handler not found, passing through!" }
|
||||
return@intercept call
|
||||
}
|
||||
|
||||
val solved = captcha(captchaRequest)
|
||||
logger.d { "Captcha solved $solved" }
|
||||
if (solved !is CaptchaResponse.Success) {
|
||||
val failure = solved as CaptchaResponse.Failed
|
||||
logger.w(failure.error) { "Captcha failed" }
|
||||
return@intercept call
|
||||
}
|
||||
|
||||
logger.d { "Refiring" }
|
||||
req.apply {
|
||||
header("X-Captcha-Key", solved.token)
|
||||
if (captchaRequest.captchaSessionId != null) {
|
||||
header("X-Captcha-Session-Id", captchaRequest.captchaSessionId)
|
||||
}
|
||||
if (captchaRequest.captchaRqtoken != null) {
|
||||
header("X-Captcha-Rqtoken", captchaRequest.captchaRqtoken)
|
||||
}
|
||||
}.let { execute(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun buildApiClient() = HttpClient {
|
||||
expectSuccess = true
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
install(WebSockets)
|
||||
install(HttpCookies)
|
||||
defaultRequest {
|
||||
url("https://discord.com/api/v9/")
|
||||
headers.appendAll(ApiConstants.baseHeaders)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package moe.lava.neon.core.api
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.ZoneId
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
object ApiConstants {
|
||||
val superProps = Base64.encode(Json.encodeToString(SuperProperties()).encodeToByteArray())
|
||||
val baseHeaders = mapOf(
|
||||
"X-Debug-Options" to "bugReporterEnabled",
|
||||
"X-Discord-Locale" to "en-US",
|
||||
"X-Discord-Timezone" to ZoneId.systemDefault().id,
|
||||
"X-Super-Properties" to superProps,
|
||||
)
|
||||
const val userAgent = "Discord-Android/311020;RNA"
|
||||
}
|
||||
|
||||
// TODO: Desktop uses separate properties
|
||||
@Suppress("PropertyName")
|
||||
@Serializable
|
||||
data class SuperProperties(
|
||||
val os: String = "Android",
|
||||
val browser: String = "Discord Android",
|
||||
val device: String = platformSuperProps.device,
|
||||
val system_locale: String = platformSuperProps.systemLocale,
|
||||
val has_client_mods: Boolean = false,
|
||||
val client_version: String = "311.20 - rn",
|
||||
val release_channel: String = "googleRelease",
|
||||
val device_vendor_id: String = storedVendorId,
|
||||
val design_id: Int = 2,
|
||||
val browser_user_agent: String = "",
|
||||
val browser_version: String = "",
|
||||
val os_version: String = platformSuperProps.osVersion,
|
||||
val client_build_number: Long = 31102000334720,
|
||||
val client_event_source: String? = null,
|
||||
val client_launch_id: String = storedLaunchId,
|
||||
// TODO: this is a random snowflake
|
||||
val launch_signature: String = "1769227908736837151",
|
||||
val client_app_state: String = "active",
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
private val storedVendorId = Uuid.random().toString().lowercase()
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
private val storedLaunchId = Uuid.random().toString().lowercase()
|
||||
|
||||
internal data class PlatformProps(
|
||||
val device: String,
|
||||
val systemLocale: String,
|
||||
val osVersion: String,
|
||||
)
|
||||
|
||||
internal expect val platformSuperProps: PlatformProps
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package moe.lava.neon.core.api.captcha
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CaptchaRequest(
|
||||
val captchaKey: List<String>,
|
||||
val captchaService: String,
|
||||
val captchaSitekey: String?,
|
||||
val captchaSessionId: String?,
|
||||
val captchaRqdata: String?,
|
||||
val captchaRqtoken: String?,
|
||||
val shouldServeInvisible: Boolean? = false,
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package moe.lava.neon.core.api.captcha
|
||||
|
||||
sealed class CaptchaResponse {
|
||||
data class Success(val token: String) : CaptchaResponse()
|
||||
data class Failed(val error: Throwable) : CaptchaResponse()
|
||||
}
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
package moe.lava.neon.core.di
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.DependencyGraph
|
||||
import dev.zacsweers.metro.GraphExtension
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import moe.lava.neon.core.AppSettings
|
||||
import moe.lava.neon.core.api.ApiClient
|
||||
import moe.lava.neon.core.repository.AuthRepository
|
||||
import moe.lava.neon.core.repository.UserRepository
|
||||
|
||||
@GraphExtension
|
||||
interface AppGraph {
|
||||
val api: ApiClient
|
||||
val settings: AppSettings
|
||||
|
||||
val auth: AuthRepository
|
||||
val users: UserRepository
|
||||
val settings: AppSettings
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
package moe.lava.neon.core.di
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.Provides
|
||||
|
||||
@ContributesTo(AppScope::class)
|
||||
interface Providers {
|
||||
// @Provides
|
||||
// fun providesSettings(): Settings = Settings()
|
||||
}
|
||||
|
|
@ -1,20 +1,97 @@
|
|||
package moe.lava.neon.core.repository
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.serialization.Serializable
|
||||
import moe.lava.neon.core.AppSettings
|
||||
import moe.lava.neon.core.api.ApiClient
|
||||
import moe.lava.neon.core.api.captcha.CaptchaRequest
|
||||
import moe.lava.neon.core.api.captcha.CaptchaResponse
|
||||
|
||||
@Serializable
|
||||
private data class ExperimentResponse(
|
||||
val fingerprint: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class LoginRequest(
|
||||
val login: String,
|
||||
val password: String,
|
||||
val undelete: Boolean = false,
|
||||
val loginSource: String? = null,
|
||||
val giftCodeSkuId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class LoginResponse(
|
||||
val userId: String,
|
||||
val token: String,
|
||||
val userSettings: UserSettings,
|
||||
) {
|
||||
@Serializable
|
||||
data class UserSettings(val locale: String, val theme: String)
|
||||
}
|
||||
|
||||
sealed class AuthResponse {
|
||||
data class Success(val token: String) : AuthResponse()
|
||||
// TODO
|
||||
// data class MFARequested() : AuthResponse()
|
||||
}
|
||||
|
||||
@Inject
|
||||
@SingleIn(AppScope::class)
|
||||
class AuthRepository(private val settings: AppSettings) {
|
||||
class AuthRepository(
|
||||
private val settings: AppSettings,
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
private val logger = Logger.withTag("neon.core.repo/auth")
|
||||
var token by settings::token
|
||||
private set
|
||||
|
||||
suspend fun login(username: String, password: String) {
|
||||
// api.login(username, password)
|
||||
var fingerprint by settings::fingerprint
|
||||
private set
|
||||
|
||||
suspend fun login(
|
||||
email: String,
|
||||
password: String,
|
||||
captcha: Pair<CaptchaRequest, CaptchaResponse.Success>? = null,
|
||||
): AuthResponse {
|
||||
if (fingerprint == null) {
|
||||
fingerprint = api.client.get("experiments") {
|
||||
parameter("with_guild_experiments", "true")
|
||||
}.body<ExperimentResponse>().fingerprint
|
||||
}
|
||||
|
||||
val res = api.client.post("auth/login") {
|
||||
header("X-Fingerprint", fingerprint)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(LoginRequest(
|
||||
login = email,
|
||||
password = password,
|
||||
))
|
||||
}
|
||||
val body = res.body<LoginResponse>()
|
||||
logger.i { "Login success $body" }
|
||||
this.token = body.token
|
||||
return AuthResponse.Success(body.token)
|
||||
}
|
||||
suspend fun login(token: String) {
|
||||
|
||||
suspend fun login(token: String): String {
|
||||
this.token = token
|
||||
return token
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
token = null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package moe.lava.neon.core.api
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
@Suppress("ConstantLocale")
|
||||
internal actual val platformSuperProps = PlatformProps(
|
||||
device = "",
|
||||
systemLocale = Locale.getDefault().language,
|
||||
osVersion = "",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue