diff --git a/build.gradle.kts b/build.gradle.kts index 99f71d6..580151a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -81,6 +81,9 @@ dependencies { // CopyDown implementation("io.github.furstenheim:copy_down:1.1") + // Zip4J + implementation("net.lingala.zip4j:zip4j:2.11.5") + // JUnit testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.1") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.1") diff --git a/src/main/kotlin/de/bigboot/ggtools/fang/Config.kt b/src/main/kotlin/de/bigboot/ggtools/fang/Config.kt index e3076ba..0c3a8f6 100644 --- a/src/main/kotlin/de/bigboot/ggtools/fang/Config.kt +++ b/src/main/kotlin/de/bigboot/ggtools/fang/Config.kt @@ -7,6 +7,12 @@ import com.squareup.moshi.Moshi import org.tomlj.Toml import java.nio.file.Paths +@JsonClass(generateAdapter = true) +data class M202Config( + val arc_pw: String? = null, + val role: String? = "M202", +) + @JsonClass(generateAdapter = true) data class EmojisConfig( val accept: String = "\uD83D\uDC4D", @@ -76,6 +82,7 @@ data class RootConfig( val emojis: EmojisConfig = EmojisConfig(), val permissions: PermissionConfig = PermissionConfig(), val emu: EmuConfig = EmuConfig(), + val m202: M202Config = M202Config(), ) private val moshi = Moshi.Builder().build() diff --git a/src/main/kotlin/de/bigboot/ggtools/fang/db/M202Confirm.kt b/src/main/kotlin/de/bigboot/ggtools/fang/db/M202Confirm.kt new file mode 100644 index 0000000..22fe6b4 --- /dev/null +++ b/src/main/kotlin/de/bigboot/ggtools/fang/db/M202Confirm.kt @@ -0,0 +1,17 @@ +package de.bigboot.ggtools.fang.db + +import org.jetbrains.exposed.dao.UUIDEntity +import org.jetbrains.exposed.dao.UUIDEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.UUIDTable +import java.util.* + +class M202Confirm(id: EntityID) : UUIDEntity(id) { + companion object : UUIDEntityClass(M202Confirms) + + var token by M202Confirms.token +} + +object M202Confirms : UUIDTable() { + val token = text("token") +} diff --git a/src/main/kotlin/de/bigboot/ggtools/fang/db/migrations/V13__add_m202_confirm.kt b/src/main/kotlin/de/bigboot/ggtools/fang/db/migrations/V13__add_m202_confirm.kt new file mode 100644 index 0000000..c26a7a2 --- /dev/null +++ b/src/main/kotlin/de/bigboot/ggtools/fang/db/migrations/V13__add_m202_confirm.kt @@ -0,0 +1,19 @@ +@file:Suppress("ClassName", "ClassNaming", "unused", "LongMethod") + +package de.bigboot.ggtools.fang.db.migrations + +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context + +class V13__add_m202_confirm : BaseJavaMigration() { + override fun migrate(context: Context) { + context.connection.prepareStatement(""" + |create table if not exists M202Confirms + |( + | id binary(16) not null + | primary key, + | token text not null + |); + """.trimMargin()).execute() + } +} diff --git a/src/main/kotlin/de/bigboot/ggtools/fang/di/serviceModule.kt b/src/main/kotlin/de/bigboot/ggtools/fang/di/serviceModule.kt index 90db579..9a4ad78 100644 --- a/src/main/kotlin/de/bigboot/ggtools/fang/di/serviceModule.kt +++ b/src/main/kotlin/de/bigboot/ggtools/fang/di/serviceModule.kt @@ -18,4 +18,5 @@ val serviceModule = module { single { QueueMessageService() } bind AutostartService::class single { StatusUpdateService() } bind AutostartService::class single { MistforgeService() } bind AutostartService::class + single { M202VerifyServiceImpl() } bind AutostartService::class } diff --git a/src/main/kotlin/de/bigboot/ggtools/fang/service/M202VerifyServiceImpl.kt b/src/main/kotlin/de/bigboot/ggtools/fang/service/M202VerifyServiceImpl.kt new file mode 100644 index 0000000..cb8b7bb --- /dev/null +++ b/src/main/kotlin/de/bigboot/ggtools/fang/service/M202VerifyServiceImpl.kt @@ -0,0 +1,129 @@ +package de.bigboot.ggtools.fang.service + +import de.bigboot.ggtools.fang.Config +import de.bigboot.ggtools.fang.db.M202Confirm +import de.bigboot.ggtools.fang.db.M202Confirms +import de.bigboot.ggtools.fang.utils.* +import discord4j.core.GatewayDiscordClient +import discord4j.core.event.domain.message.MessageCreateEvent +import discord4j.core.`object`.entity.channel.PrivateChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.withContext +import net.lingala.zip4j.io.inputstream.ZipInputStream +import net.lingala.zip4j.model.LocalFileHeader +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.ByteString.Companion.encode +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.transactions.transaction +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.InputStream +import java.security.MessageDigest +import javax.xml.parsers.DocumentBuilderFactory + + +class M202VerifyServiceImpl : AutostartService, KoinComponent { + private val client by inject() + private val http = OkHttpClient() + private val database by inject() + private val hasher = MessageDigest.getInstance("SHA-256") + + init { + client.eventDispatcher.on() + .onEachSafe(this::handleCommandEvent) + .launch() + } + + private fun extractFileFromEncryptedZip(input: InputStream, password: String?, targetFileName: String): InputStream? { + val zip = ZipInputStream(input, password?.toCharArray()) + + var localFileHeader: LocalFileHeader? = zip.nextEntry + while (localFileHeader != null) { + if(localFileHeader.fileName == targetFileName) { + return zip + } + localFileHeader = zip.nextEntry + } + + return null + } + + private fun getUserTokens(input: InputStream, game: String): List { + val doc = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(input) + + val tokens = doc.getElementsByTagName("user") + return sequence { + for (i in 0 until tokens.length) { + val element = tokens.item(i) + if(element.attributes.getNamedItem("gameabbr").nodeValue == game) { + yield(element.attributes.getNamedItem("token").nodeValue) + } + } + }.toList() + } + + private fun verifyToken(token: String) = transaction(database) { + val hash = hasher.digest(token.encode(Charsets.US_ASCII).toByteArray()).fold("") { str, it -> + str + "%02x".format(it) + } + + if (M202Confirm.find { M202Confirms.token eq hash }.any()) { + return@transaction false + } + + M202Confirm.new { this.token = hash } + + return@transaction true + } + + private suspend fun handleCommandEvent(event: MessageCreateEvent) { + val msg = event.message + val channel = event.message.channel.awaitSingle(); + + if (channel !is PrivateChannel) { + return + } + + val arcZone = msg.attachments.firstOrNull { it.filename == "ArcZone.dat" } ?: return + + if (arcZone.size > 2 * 1024 * 1024) { + channel.createMessageCompat { + content("It looks like you tried to verify your access to M202, but the file you sent exceeds the size limit, if you believe this is an error please contact one of the moderators.") + }.awaitSafe() + return + } + + val response = http.newCall(Request.Builder().url(arcZone.url).build()).await() + val body = response.body() ?: return + + val tokens = withContext(Dispatchers.IO) { + extractFileFromEncryptedZip(body.byteStream(), Config.m202.arc_pw, "ArcZone.xml")?.let { + getUserTokens(it, "m202") + } + } ?: return + + val valid = tokens.any { verifyToken(it) } + + if(!valid) { + channel.createMessageCompat { + content("This token has already been used to verify access and cannot be used again.") + }.awaitSafe() + return + } + + for (guild in client.guilds.awaitSafe()) + { + val member = guild.getMemberById(msg.author.get().id).awaitSafe() ?: continue + val role = guild.roles.filter { it.name == Config.m202.role }.awaitFirstOrNull() ?: continue + member.addRole(role.id, "Assigned by fang using token verification").awaitSafe() + + channel.createMessageCompat { + content("Role ${role.name} has been assigned in ${guild.name}.") + }.awaitSafe() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/bigboot/ggtools/fang/utils/OkHttpExt.kt b/src/main/kotlin/de/bigboot/ggtools/fang/utils/OkHttpExt.kt new file mode 100644 index 0000000..4dc42bf --- /dev/null +++ b/src/main/kotlin/de/bigboot/ggtools/fang/utils/OkHttpExt.kt @@ -0,0 +1,25 @@ +package de.bigboot.ggtools.fang.utils + +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response +import java.io.IOException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class OkHttpExt { + +}suspend fun Call.await(): Response { + return suspendCancellableCoroutine { continuation -> + enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) + } + + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + }) + } +} \ No newline at end of file