diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml new file mode 100644 index 0000000..2b63946 --- /dev/null +++ b/.idea/uiDesigner.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/kotlin/Client.kt b/src/main/kotlin/Client.kt index 2c161ed..f608a00 100644 --- a/src/main/kotlin/Client.kt +++ b/src/main/kotlin/Client.kt @@ -1,80 +1,76 @@ import io.github.cdimascio.dotenv.dotenv - +import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* import io.ktor.server.plugins.contentnegotiation.* -import jdk.nashorn.internal.parser.Token import kotlinx.coroutines.* -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import structures.HttpProvider -import structures.api.Response import structures.api.account.OrgAccount import structures.api.account.TokenData -import java.util.* +import structures.wrapped.Base import kotlin.coroutines.CoroutineContext +object Globals { + val eventEmitter = EventEmitter() + val serializer = Json { + ignoreUnknownKeys = true + prettyPrint = true + explicitNulls = false + } +} -object Client : CoroutineScope { - +class Client : CoroutineScope { + val api = HttpProvider(this) private var parentJob = Job() - val eventEmitter = EventEmitter() - val json = Json { ignoreUnknownKeys = true } lateinit var application : structures.api.application.Application lateinit var orgAccount: OrgAccount lateinit var tokenData : TokenData override val coroutineContext: CoroutineContext get() = Dispatchers.Default + parentJob - private val server = embeddedServer( - Netty, - port = 8000, - host = "localhost", - parentCoroutineContext = coroutineContext - ) { - configureRouting() - install(ContentNegotiation) - } init { dotenv { systemProperties = true } } - - inline fun on( + inline fun on( eventName: String, - crossinline fn: (T) -> Unit + crossinline fn: suspend (T) -> Unit ) { launch { - eventEmitter.events.collect { + Globals.eventEmitter.events.collect { when (eventName) { "pull_request" -> { - fn(json.decodeFromString(it)) + fn(it as T) } } } } } suspend fun loginAsync() { - verifyWithJwt() - verifyWithInstallationApp() - getInstallationToken() - - } - private suspend fun verifyWithJwt() { - application = HttpProvider.loginIntoApplicationAsync().await() - } - private suspend fun verifyWithInstallationApp() { - orgAccount = HttpProvider.loginIntoOrgAsync().await() - - } - private suspend fun getInstallationToken() { - tokenData = HttpProvider.getInstallationToken(orgAccount).await() + coroutineScope { + withContext(Dispatchers.Default) { + application = api.loginIntoApplicationAsync().await() + orgAccount = api.loginIntoOrgAsync().await() + tokenData = api.getInstallationTokenAsync(orgAccount).await() + } + } } /** * This should start last in order to prevent blocking of thread and listeners */ fun startWebhookListener() { - server.start(wait = true) + embeddedServer( + Netty, + port = 8000, + host = "localhost", + parentCoroutineContext = coroutineContext + ) { + configureRouting(this@Client) + install(ContentNegotiation) { + json(Globals.serializer) + } + }.start(wait = true).also { println("Started server") } } } \ No newline at end of file diff --git a/src/main/kotlin/EventEmitter.kt b/src/main/kotlin/EventEmitter.kt index 36f58a0..1c5177c 100644 --- a/src/main/kotlin/EventEmitter.kt +++ b/src/main/kotlin/EventEmitter.kt @@ -1,11 +1,13 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import structures.api.Response +import structures.wrapped.Base -class EventEmitter { - private val _events = MutableSharedFlow() // private mutable shared flow +class EventEmitter { + private val _events = MutableSharedFlow() // private mutable shared flow val events = _events.asSharedFlow() // publicly exposed as read-only shared flow - suspend fun produceEvent(event: String) { + suspend fun produceEvent(event: T) { _events.emit(event) // suspends until all subscribers receive it } } \ No newline at end of file diff --git a/src/main/kotlin/HttpClientExtensions.kt b/src/main/kotlin/HttpClientExtensions.kt index 51fe0ee..6be2af3 100644 --- a/src/main/kotlin/HttpClientExtensions.kt +++ b/src/main/kotlin/HttpClientExtensions.kt @@ -1,34 +1,49 @@ +import HashUtils.verifySignature +import arrow.core.Either import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.ktor.util.pipeline.* import kotlinx.coroutines.* -import kotlinx.serialization.json.Json -import java.io.InputStreamReader -import java.io.Reader +import kotlinx.serialization.decodeFromString import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec import kotlin.experimental.xor -fun Application.configureRouting() { +fun Application.configureRouting(client: Client) { routing { - pullRequests() + + pullRequests(client) } } -fun Routing.pullRequests() { +fun Routing.pullRequests(client: Client) { post("/pulls") { val secret = call.request.headers["X-Hub-Signature-256"]!! - val text = call.receiveText() - if(!HashUtils.secureCompare(secret, HashUtils.sha256(text))) { - call.respond(HttpStatusCode.Unauthorized, "Nice try") + val event = call.request.headers["X-Github-Event"]!! + val respText = call.receiveText() + when(val resp = verifySignature(secret, respText)) { + is Either.Left -> { + launch(Dispatchers.Default) { + when(event) { + "pull_request" -> { + Globals.eventEmitter.produceEvent( + structures.wrapped.PullRequestsManager(client, + Globals.serializer.decodeFromString(resp.value) + ) + ) + } + } + call.respond(HttpStatusCode.OK) + }.join() + } + is Either.Right -> resp.value } - launch(Dispatchers.Default) { - Client.eventEmitter.produceEvent(text) - }.join() + } } @@ -63,4 +78,11 @@ object HashUtils { val hash: ByteArray = hasher.doFinal(body.toByteArray()) return "sha256=${hash.toHex()}" } + + suspend inline fun PipelineContext.verifySignature(secret: String, resp: String) : Either { + if(secureCompare(secret, sha256(resp))) { + return Either.Left(resp) + } + return Either.Right(call.respond(HttpStatusCode.Unauthorized, "Nice try")) + } } diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index f7e0ba2..d8dae0d 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -1,18 +1,23 @@ import kotlinx.coroutines.* -import structures.api.PullRequests +import structures.api.application.PullRequestAction +import structures.options.PullRequestCreateOptions +import structures.wrapped.PullRequestsManager fun main() = runBlocking { - - - Client.loginAsync() - println(Client.orgAccount) - Client.on("pull_request") { pr_event -> - println(pr_event) + val sernClient = Client() + sernClient.loginAsync() + + sernClient.on("pull_request") { pr_event -> + when(pr_event.action) { + PullRequestAction.Opened -> { + println("new pull request opened") + } + else -> Unit + } } - Client.startWebhookListener() + sernClient.startWebhookListener() } - diff --git a/src/main/kotlin/structures/HttpProvider.kt b/src/main/kotlin/structures/HttpProvider.kt index bb3b679..813072b 100644 --- a/src/main/kotlin/structures/HttpProvider.kt +++ b/src/main/kotlin/structures/HttpProvider.kt @@ -1,5 +1,5 @@ package structures - +import Client import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* @@ -9,29 +9,53 @@ import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.coroutines.* -import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString +import structures.api.Response import structures.api.account.OrgAccount import structures.api.account.TokenData import structures.api.application.Application +import structures.options.PostOptions import kotlin.coroutines.CoroutineContext -object HttpProvider : CoroutineScope { - private val httpClient = HttpClient(CIO) { +class HttpProvider(private val client: Client) : CoroutineScope { + val httpClient = HttpClient(CIO) { install(ContentNegotiation) { - json(Json { - prettyPrint = true - }) + json(Globals.serializer) + } + } + enum class ApiType { + Rest, + App + } + val orgName = "sern-handler" + val baseLink = "https://api.github.com" + + fun authHeader(type: ApiType): Pair { + return HttpHeaders.Authorization to when(type) { + ApiType.App -> "Bearer ${JWTProvider.jwt}" + ApiType.Rest -> "Bearer ${client.tokenData.token}" + } + } + val contentTypeHeader = HttpHeaders.Accept to "application/vnd.github+json" + inline fun postAsync(path: String, body: V) : Deferred { + return async { + httpClient.post("$baseLink/$path") { + headers { + append(HttpHeaders.ContentType, "application/json") + append(contentTypeHeader) + append(authHeader(ApiType.Rest)) + } + setBody(body) + }.body() } } - private const val orgName = "sern-handler" - private const val baseLink = "https://api.github.com" fun loginIntoApplicationAsync() : Deferred { return async { httpClient.request("$baseLink/app") { headers { - append(HttpHeaders.Accept, "application/vnd.github+json") - append(HttpHeaders.Authorization, "Bearer ${JWTProvider.jwt}") + append(contentTypeHeader) + append(authHeader(ApiType.App)) } }.body() } @@ -41,23 +65,27 @@ object HttpProvider : CoroutineScope { // GHAppInstallation httpClient.request("$baseLink/orgs/$orgName/installation") { headers { - append(HttpHeaders.Accept, "application/vnd.github+json") - append(HttpHeaders.Authorization, "Bearer ${JWTProvider.jwt}") + append(contentTypeHeader) + append(authHeader(ApiType.App)) } }.body() } } - fun getInstallationToken(orgAccount: OrgAccount): Deferred { + fun getInstallationTokenAsync(orgAccount: OrgAccount): Deferred { return async { httpClient.post("$baseLink/app/installations/${orgAccount.id}/access_tokens") { headers { - append(HttpHeaders.Accept, "application/vnd.github+json") - append(HttpHeaders.Authorization, "Bearer ${JWTProvider.jwt}") + append(contentTypeHeader) + append(authHeader(ApiType.App)) } }.body() } } override val coroutineContext: CoroutineContext get() = Dispatchers.Default + Job() -} \ No newline at end of file +} + +fun HeadersBuilder.append(name: Pair) { + append(name.first, name.second) +} diff --git a/src/main/kotlin/structures/api/PullRequest.kt b/src/main/kotlin/structures/api/PullRequest.kt index aab1173..d303b96 100644 --- a/src/main/kotlin/structures/api/PullRequest.kt +++ b/src/main/kotlin/structures/api/PullRequest.kt @@ -29,7 +29,7 @@ data class PullRequest( val labels: List