diff --git a/core/src/main/kotlin/me/madhead/tyzenhaus/core/telegram/updates/history/HistoryCommandUpdateProcessor.kt b/core/src/main/kotlin/me/madhead/tyzenhaus/core/telegram/updates/history/HistoryCommandUpdateProcessor.kt index d69d1769..feb2639d 100644 --- a/core/src/main/kotlin/me/madhead/tyzenhaus/core/telegram/updates/history/HistoryCommandUpdateProcessor.kt +++ b/core/src/main/kotlin/me/madhead/tyzenhaus/core/telegram/updates/history/HistoryCommandUpdateProcessor.kt @@ -9,13 +9,19 @@ import dev.inmo.tgbotapi.types.message.content.TextContent import dev.inmo.tgbotapi.types.message.textsources.BotCommandTextSource import dev.inmo.tgbotapi.types.update.MessageUpdate import dev.inmo.tgbotapi.types.update.abstracts.Update +import java.time.Duration +import java.time.Instant +import java.util.UUID import me.madhead.tyzenhaus.core.telegram.updates.UpdateProcessor import me.madhead.tyzenhaus.core.telegram.updates.UpdateReaction import me.madhead.tyzenhaus.core.telegram.updates.groupId import me.madhead.tyzenhaus.core.telegram.updates.userId +import me.madhead.tyzenhaus.entity.api.token.APIToken +import me.madhead.tyzenhaus.entity.api.token.Scope import me.madhead.tyzenhaus.entity.dialog.state.DialogState import me.madhead.tyzenhaus.entity.group.config.GroupConfig import me.madhead.tyzenhaus.i18.I18N +import me.madhead.tyzenhaus.repository.APITokenRepository import org.apache.logging.log4j.LogManager /** @@ -24,6 +30,7 @@ import org.apache.logging.log4j.LogManager class HistoryCommandUpdateProcessor( private val requestsExecutor: RequestsExecutor, private val me: ExtendedBot, + private val apiTokenRepository: APITokenRepository, ) : UpdateProcessor { companion object { private val logger = LogManager.getLogger(HistoryCommandUpdateProcessor::class.java)!! @@ -40,9 +47,18 @@ class HistoryCommandUpdateProcessor( { logger.debug("{} asked for history in {}", update.userId, update.groupId) + val token = APIToken( + token = UUID.randomUUID(), + groupId = update.groupId, + scope = Scope.HISTORY, + validUntil = Instant.now() + Duration.ofMinutes(30) + ) + + apiTokenRepository.save(token) + requestsExecutor.sendMessage( chatId = update.data.chat.id, - text = I18N(groupConfig?.language)["history.response", me.username.usernameWithoutAt, "TOKEN"], + text = I18N(groupConfig?.language)["history.response", me.username.usernameWithoutAt, token.token.toString()], parseMode = MarkdownV2, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 54dede4a..ff4096cc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,8 @@ kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin kotlinx-coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version.ref = "kotlinx-coroutines" } ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } ktor-server-metrics-micrometer = { module = "io.ktor:ktor-server-metrics-micrometer", version.ref = "ktor" } +ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" } kotlinx-serialization-bom = { module = "org.jetbrains.kotlinx:kotlinx-serialization-bom", version.ref = "kotlinx-serialization" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } @@ -45,4 +47,4 @@ picocli = { module = "info.picocli:picocli", version.ref = "picocli" } [bundles] boms = ["kotlin-bom", "kotlinx-serialization-bom", "kotlinx-coroutines-bom", "log4j-bom", "junit-bom"] -ktor = ["ktor-server-netty", "ktor-server-metrics-micrometer", "koin-ktor"] +ktor = ["ktor-server-netty", "ktor-server-metrics-micrometer", "ktor-server-auth", "koin-ktor", "ktor-serialization-kotlinx-json"] diff --git a/launcher/fly/requests.http b/launcher/fly/requests.http index cce35b0f..1ab082c9 100644 --- a/launcher/fly/requests.http +++ b/launcher/fly/requests.http @@ -8,3 +8,7 @@ Content-Type: application/json { "url": "https://{{ngrok}}/{{telegram_token}}" } + +### Get group members +GET http://{{local}}/app/api/group/members +Authorization: Bearer {{api_token}} diff --git a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/koin/db.kt b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/koin/db.kt index 34bbaff4..3b095e08 100644 --- a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/koin/db.kt +++ b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/koin/db.kt @@ -3,6 +3,7 @@ package me.madhead.tyzenhaus.launcher.fly.koin import io.ktor.server.config.ApplicationConfig import java.net.URI import javax.sql.DataSource +import me.madhead.tyzenhaus.repository.APITokenRepository import me.madhead.tyzenhaus.repository.BalanceRepository import me.madhead.tyzenhaus.repository.DialogStateRepository import me.madhead.tyzenhaus.repository.GroupConfigRepository @@ -11,6 +12,7 @@ import me.madhead.tyzenhaus.repository.SupergroupRepository import me.madhead.tyzenhaus.repository.TransactionRepository import org.koin.dsl.module import org.postgresql.ds.PGSimpleDataSource +import me.madhead.tyzenhaus.repository.postgresql.api.token.APITokenRepository as PostgreSQLAPITokenRepository import me.madhead.tyzenhaus.repository.postgresql.balance.BalanceRepository as PostgreSQLBalanceRepository import me.madhead.tyzenhaus.repository.postgresql.dialog.state.DialogStateRepository as PostgreSQLDialogStateRepository import me.madhead.tyzenhaus.repository.postgresql.group.config.GroupConfigRepository as PostgreSQLGroupConfigRepository @@ -52,4 +54,8 @@ val dbModule = module { single { PostgreSQLSupegroupRepository(get()) } + + single { + PostgreSQLAPITokenRepository(get()) + } } diff --git a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/koin/pipeline.kt b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/koin/pipeline.kt index baa801c3..a4708829 100644 --- a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/koin/pipeline.kt +++ b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/koin/pipeline.kt @@ -135,6 +135,7 @@ val pipelineModule = module { HistoryCommandUpdateProcessor( requestsExecutor = get(), me = get(), + apiTokenRepository = get(), ) } single { diff --git a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/modules/Tyzenhaus.kt b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/modules/Tyzenhaus.kt index e212fd14..5c1f2f75 100644 --- a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/modules/Tyzenhaus.kt +++ b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/modules/Tyzenhaus.kt @@ -1,24 +1,34 @@ package me.madhead.tyzenhaus.launcher.fly.modules +import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application import io.ktor.server.application.install +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.bearer import io.ktor.server.metrics.micrometer.MicrometerMetrics import io.ktor.server.plugins.callloging.CallLogging import io.ktor.server.plugins.compression.Compression +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.plugins.defaultheaders.DefaultHeaders import io.ktor.server.routing.routing import io.micrometer.prometheus.PrometheusMeterRegistry +import java.time.Instant +import java.util.UUID import me.madhead.tyzenhaus.launcher.fly.koin.configModule import me.madhead.tyzenhaus.launcher.fly.koin.dbModule import me.madhead.tyzenhaus.launcher.fly.koin.jsonModule import me.madhead.tyzenhaus.launcher.fly.koin.metricsModule import me.madhead.tyzenhaus.launcher.fly.koin.pipelineModule +import me.madhead.tyzenhaus.launcher.fly.koin.serviceModule import me.madhead.tyzenhaus.launcher.fly.koin.telegramModule import me.madhead.tyzenhaus.launcher.fly.routes.metrics import me.madhead.tyzenhaus.launcher.fly.routes.miniApp import me.madhead.tyzenhaus.launcher.fly.routes.miniAppAPI import me.madhead.tyzenhaus.launcher.fly.routes.webhook +import me.madhead.tyzenhaus.launcher.fly.security.APITokenPrincipal +import me.madhead.tyzenhaus.repository.APITokenRepository import org.koin.ktor.ext.get +import org.koin.ktor.ext.inject import org.koin.ktor.plugin.Koin /** @@ -32,18 +42,38 @@ fun Application.tyzenhaus() { install(Koin) { modules( configModule(environment.config), + dbModule, + serviceModule, + pipelineModule, metricsModule, telegramModule, jsonModule, - pipelineModule, - dbModule, ) } + install(ContentNegotiation) { + json(this@tyzenhaus.get()) + } + install(MicrometerMetrics) { this.registry = this@tyzenhaus.get() } + install(Authentication) { + bearer("api") { + authenticate { credential -> + val token = try { + UUID.fromString(credential.token) + } catch (_: Exception) { + return@authenticate null + } + val tokenRepository by inject() + + tokenRepository.get(token)?.takeIf { it.validUntil > Instant.now() }?.let { APITokenPrincipal(it.groupId, it.scope) } + } + } + } + routing { webhook() metrics() diff --git a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/Metrics.kt b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/Metrics.kt index bc7f3981..ded556b1 100644 --- a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/Metrics.kt +++ b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/Metrics.kt @@ -5,6 +5,7 @@ import io.ktor.server.config.ApplicationConfig import io.ktor.server.response.respond import io.ktor.server.routing.Route import io.ktor.server.routing.get +import io.ktor.server.routing.localPort import io.micrometer.prometheus.PrometheusMeterRegistry import org.koin.ktor.ext.inject @@ -14,10 +15,9 @@ import org.koin.ktor.ext.inject fun Route.metrics() { val registry by inject() val config by inject() - val managementPort = config.property("deployment.managementPort").getString().toInt() - get("metrics") { - if (call.request.local.port == managementPort) { + localPort(config.property("deployment.managementPort").getString().toInt()) { + get("metrics") { this.call.respond(registry.scrape()) } } diff --git a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/MiniApp.kt b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/MiniApp.kt index 006ad721..847de342 100644 --- a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/MiniApp.kt +++ b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/MiniApp.kt @@ -4,6 +4,7 @@ import io.ktor.server.config.ApplicationConfig import io.ktor.server.http.content.react import io.ktor.server.http.content.singlePageApplication import io.ktor.server.routing.Route +import io.ktor.server.routing.localPort import org.koin.ktor.ext.inject /** @@ -13,8 +14,10 @@ fun Route.miniApp() { val config by inject() val miniAppPath = config.property("telegram.miniApp.path").getString() - singlePageApplication { - applicationRoute = "app" - react(miniAppPath) + localPort(config.property("deployment.port").getString().toInt()) { + singlePageApplication { + applicationRoute = "app" + react(miniAppPath) + } } } diff --git a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/MiniAppAPI.kt b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/MiniAppAPI.kt index dee32ad9..a056d86f 100644 --- a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/MiniAppAPI.kt +++ b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/MiniAppAPI.kt @@ -1,20 +1,40 @@ package me.madhead.tyzenhaus.launcher.fly.routes -import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode import io.ktor.server.application.call -import io.ktor.server.response.respondText +import io.ktor.server.auth.authenticate +import io.ktor.server.auth.principal +import io.ktor.server.config.ApplicationConfig +import io.ktor.server.response.respond import io.ktor.server.routing.Route import io.ktor.server.routing.get +import io.ktor.server.routing.localPort import io.ktor.server.routing.route -import java.time.LocalDateTime +import me.madhead.tyzenhaus.core.service.GroupMembersService +import me.madhead.tyzenhaus.launcher.fly.security.APITokenPrincipal +import org.koin.ktor.ext.inject /** * Routes for [Telegram Mini App](https://core.telegram.org/bots/webapps) API. */ fun Route.miniAppAPI() { - route("/app/api") { - get("test") { - call.respondText("""{"result":"ok", "date":"${LocalDateTime.now()}"}""", ContentType.Application.Json) + val config by inject() + val groupMembersService by inject() + + localPort(config.property("deployment.port").getString().toInt()) { + route("/app/api") { + authenticate("api") { + get("/group/members") { + val principal = call.principal()!! + val members = groupMembersService.groupMembers(principal.groupId) + + if (members != null) { + call.respond(members) + } else { + call.respond(HttpStatusCode.NotFound) + } + } + } } } } diff --git a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/Webhook.kt b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/Webhook.kt index e981cb0e..83808411 100644 --- a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/Webhook.kt +++ b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/routes/Webhook.kt @@ -7,6 +7,7 @@ import io.ktor.server.config.ApplicationConfig import io.ktor.server.request.receiveText import io.ktor.server.response.respond import io.ktor.server.routing.Route +import io.ktor.server.routing.localPort import io.ktor.server.routing.post import kotlinx.serialization.json.Json import me.madhead.tyzenhaus.core.telegram.updates.UpdateProcessingPipeline @@ -19,29 +20,26 @@ import org.koin.ktor.ext.inject fun Route.webhook() { val logger = LogManager.getLogger("me.madhead.tyzenhaus.launcher.fly.routes.Webhook") val config by inject() - val port = config.property("deployment.port").getString().toInt() val json by inject() val pipeline by inject() - post(config.property("telegram.token").getString()) { - if (call.request.local.port != port) { - return@post - } + localPort(config.property("deployment.port").getString().toInt()) { + post(config.property("telegram.token").getString()) { + try { + val payload = call.receiveText() - try { - val payload = call.receiveText() + logger.debug("Request payload: {}", payload) - logger.debug("Request payload: {}", payload) + val update = json.decodeFromString(UpdateDeserializationStrategy, payload) - val update = json.decodeFromString(UpdateDeserializationStrategy, payload) + logger.info("Update object: {}", update) - logger.info("Update object: {}", update) + pipeline.process(update) + } catch (ignored: Exception) { + logger.error("Failed to handle the request", ignored) + } - pipeline.process(update) - } catch (ignored: Exception) { - logger.error("Failed to handle the request", ignored) + call.respond(HttpStatusCode.OK) } - - call.respond(HttpStatusCode.OK) } } diff --git a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/security/APITokenPrincipal.kt b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/security/APITokenPrincipal.kt new file mode 100644 index 00000000..a4ab25c1 --- /dev/null +++ b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/security/APITokenPrincipal.kt @@ -0,0 +1,9 @@ +package me.madhead.tyzenhaus.launcher.fly.security + +import io.ktor.server.auth.Principal +import me.madhead.tyzenhaus.entity.api.token.Scope + +data class APITokenPrincipal( + val groupId: Long, + val scope: Scope, +) : Principal diff --git a/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/security/AuthorizationPlugin.kt b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/security/AuthorizationPlugin.kt new file mode 100644 index 00000000..54e71309 --- /dev/null +++ b/launcher/fly/src/main/kotlin/me/madhead/tyzenhaus/launcher/fly/security/AuthorizationPlugin.kt @@ -0,0 +1,27 @@ +package me.madhead.tyzenhaus.launcher.fly.security + +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.createRouteScopedPlugin +import io.ktor.server.auth.AuthenticationChecked +import io.ktor.server.auth.principal +import io.ktor.server.response.respond +import me.madhead.tyzenhaus.entity.api.token.Scope + +val AuthorizationPlugin = createRouteScopedPlugin( + name = "AuthorizationPlugin", + createConfiguration = ::AuthorizationPluginConfiguration, +) { + pluginConfig.apply { + on(AuthenticationChecked) { call -> + val principal = call.principal() + + if (principal?.scope != pluginConfig.scope) { + call.respond(HttpStatusCode.Forbidden) + } + } + } +} + +data class AuthorizationPluginConfiguration( + var scope: Scope? = null +)