mirror of
https://github.com/ReVanced/revanced-api.git
synced 2026-01-18 17:03:57 +00:00
chore: Use Kotlin for ReVanced API (#169)
This commit converts the entire project to a KTor project written in Kotlin. Various APIs have been updated, removed, or changed. A proxy is present to allow migration between the old and this API, which can serve requests to endpoints from the old API.
This commit is contained in:
34
src/main/kotlin/app/revanced/api/command/MainCommand.kt
Normal file
34
src/main/kotlin/app/revanced/api/command/MainCommand.kt
Normal file
@@ -0,0 +1,34 @@
|
||||
package app.revanced.api.command
|
||||
|
||||
import picocli.CommandLine
|
||||
import java.util.*
|
||||
|
||||
internal val applicationVersion = MainCommand::class.java.getResourceAsStream(
|
||||
"/app/revanced/api/version.properties",
|
||||
)?.use { stream ->
|
||||
Properties().apply {
|
||||
load(stream)
|
||||
}.getProperty("version")
|
||||
} ?: "v0.0.0"
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
CommandLine(MainCommand).execute(*args).let(System::exit)
|
||||
}
|
||||
|
||||
private object CLIVersionProvider : CommandLine.IVersionProvider {
|
||||
override fun getVersion() =
|
||||
arrayOf(
|
||||
"ReVanced API $applicationVersion",
|
||||
)
|
||||
}
|
||||
|
||||
@CommandLine.Command(
|
||||
name = "revanced-api",
|
||||
description = ["API server for ReVanced"],
|
||||
mixinStandardHelpOptions = true,
|
||||
versionProvider = CLIVersionProvider::class,
|
||||
subcommands = [
|
||||
StartAPICommand::class,
|
||||
],
|
||||
)
|
||||
private object MainCommand
|
||||
46
src/main/kotlin/app/revanced/api/command/StartAPICommand.kt
Normal file
46
src/main/kotlin/app/revanced/api/command/StartAPICommand.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
package app.revanced.api.command
|
||||
|
||||
import app.revanced.api.configuration.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.jetty.*
|
||||
import picocli.CommandLine
|
||||
import java.io.File
|
||||
|
||||
@CommandLine.Command(
|
||||
name = "start",
|
||||
description = ["Start the API server"],
|
||||
)
|
||||
internal object StartAPICommand : Runnable {
|
||||
@CommandLine.Option(
|
||||
names = ["-h", "--host"],
|
||||
description = ["The host address to bind to."],
|
||||
showDefaultValue = CommandLine.Help.Visibility.ALWAYS,
|
||||
)
|
||||
private var host: String = "0.0.0.0"
|
||||
|
||||
@CommandLine.Option(
|
||||
names = ["-p", "--port"],
|
||||
description = ["The port to listen on."],
|
||||
showDefaultValue = CommandLine.Help.Visibility.ALWAYS,
|
||||
)
|
||||
private var port: Int = 8888
|
||||
|
||||
@CommandLine.Option(
|
||||
names = ["-c", "--config"],
|
||||
description = ["The path to the configuration file."],
|
||||
showDefaultValue = CommandLine.Help.Visibility.ALWAYS,
|
||||
)
|
||||
private var configFile = File("configuration.toml")
|
||||
|
||||
override fun run() {
|
||||
embeddedServer(Jetty, port, host) {
|
||||
configureDependencies(configFile)
|
||||
configureHTTP()
|
||||
configureSerialization()
|
||||
configureSecurity()
|
||||
configureOpenAPI()
|
||||
configureLogging()
|
||||
configureRouting()
|
||||
}.start(wait = true)
|
||||
}
|
||||
}
|
||||
146
src/main/kotlin/app/revanced/api/configuration/Dependencies.kt
Normal file
146
src/main/kotlin/app/revanced/api/configuration/Dependencies.kt
Normal file
@@ -0,0 +1,146 @@
|
||||
package app.revanced.api.configuration
|
||||
|
||||
import app.revanced.api.configuration.repository.AnnouncementRepository
|
||||
import app.revanced.api.configuration.repository.BackendRepository
|
||||
import app.revanced.api.configuration.repository.ConfigurationRepository
|
||||
import app.revanced.api.configuration.repository.GitHubBackendRepository
|
||||
import app.revanced.api.configuration.services.*
|
||||
import app.revanced.api.configuration.services.AnnouncementService
|
||||
import app.revanced.api.configuration.services.ApiService
|
||||
import app.revanced.api.configuration.services.AuthService
|
||||
import app.revanced.api.configuration.services.OldApiService
|
||||
import app.revanced.api.configuration.services.PatchesService
|
||||
import com.akuleshov7.ktoml.Toml
|
||||
import com.akuleshov7.ktoml.source.decodeFromStream
|
||||
import io.github.cdimascio.dotenv.Dotenv
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.okhttp.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.auth.*
|
||||
import io.ktor.client.plugins.auth.providers.*
|
||||
import io.ktor.client.plugins.cache.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.resources.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.application.*
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNamingStrategy
|
||||
import org.jetbrains.exposed.sql.Database
|
||||
import org.jetbrains.exposed.sql.transactions.TransactionManager
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.core.parameter.parameterArrayOf
|
||||
import org.koin.dsl.module
|
||||
import org.koin.ktor.plugin.Koin
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun Application.configureDependencies(
|
||||
configFile: File,
|
||||
) {
|
||||
val globalModule = module {
|
||||
single {
|
||||
Dotenv.configure().load()
|
||||
}
|
||||
|
||||
factory { params ->
|
||||
val defaultRequestUri: String = params.get<String>()
|
||||
val configBlock = params.getOrNull<(HttpClientConfig<OkHttpConfig>.() -> Unit)>() ?: {}
|
||||
|
||||
HttpClient(OkHttp) {
|
||||
defaultRequest { url(defaultRequestUri) }
|
||||
|
||||
configBlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val repositoryModule = module {
|
||||
single<BackendRepository> {
|
||||
GitHubBackendRepository(
|
||||
get {
|
||||
val defaultRequestUri = "https://api.github.com"
|
||||
val configBlock: HttpClientConfig<OkHttpConfig>.() -> Unit = {
|
||||
install(HttpCache)
|
||||
install(Resources)
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
get<Dotenv>()["BACKEND_API_TOKEN"]?.let {
|
||||
install(Auth) {
|
||||
bearer {
|
||||
loadTokens {
|
||||
BearerTokens(
|
||||
accessToken = it,
|
||||
refreshToken = "", // Required dummy value
|
||||
)
|
||||
}
|
||||
|
||||
sendWithoutRequest { true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parameterArrayOf(defaultRequestUri, configBlock)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
single<ConfigurationRepository> {
|
||||
Toml.decodeFromStream(configFile.inputStream())
|
||||
}
|
||||
|
||||
single {
|
||||
val dotenv = get<Dotenv>()
|
||||
|
||||
TransactionManager.defaultDatabase = Database.connect(
|
||||
url = dotenv["DB_URL"],
|
||||
user = dotenv["DB_USER"],
|
||||
password = dotenv["DB_PASSWORD"],
|
||||
)
|
||||
|
||||
AnnouncementRepository()
|
||||
}
|
||||
}
|
||||
|
||||
val serviceModule = module {
|
||||
single {
|
||||
val dotenv = get<Dotenv>()
|
||||
|
||||
val jwtSecret = dotenv["JWT_SECRET"]
|
||||
val issuer = dotenv["JWT_ISSUER"]
|
||||
val validityInMin = dotenv["JWT_VALIDITY_IN_MIN"].toInt()
|
||||
|
||||
val authSHA256DigestString = dotenv["AUTH_SHA256_DIGEST"]
|
||||
|
||||
AuthService(issuer, validityInMin, jwtSecret, authSHA256DigestString)
|
||||
}
|
||||
single {
|
||||
OldApiService(
|
||||
get {
|
||||
val defaultRequestUri = get<Dotenv>()["OLD_API_URL"]
|
||||
parameterArrayOf(defaultRequestUri)
|
||||
},
|
||||
)
|
||||
}
|
||||
singleOf(::AnnouncementService)
|
||||
singleOf(::SignatureService)
|
||||
singleOf(::PatchesService)
|
||||
singleOf(::ApiService)
|
||||
}
|
||||
|
||||
install(Koin) {
|
||||
modules(
|
||||
globalModule,
|
||||
repositoryModule,
|
||||
serviceModule,
|
||||
)
|
||||
}
|
||||
}
|
||||
27
src/main/kotlin/app/revanced/api/configuration/Extensions.kt
Normal file
27
src/main/kotlin/app/revanced/api/configuration/Extensions.kt
Normal file
@@ -0,0 +1,27 @@
|
||||
package app.revanced.api.configuration
|
||||
|
||||
import io.bkbn.kompendium.core.plugin.NotarizedRoute
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.content.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.cachingheaders.*
|
||||
import io.ktor.server.response.*
|
||||
import kotlin.time.Duration
|
||||
|
||||
internal suspend fun ApplicationCall.respondOrNotFound(value: Any?) = respond(value ?: HttpStatusCode.NotFound)
|
||||
|
||||
internal fun ApplicationCallPipeline.installCache(maxAge: Duration) =
|
||||
installCache(CacheControl.MaxAge(maxAgeSeconds = maxAge.inWholeSeconds.toInt()))
|
||||
|
||||
internal fun ApplicationCallPipeline.installNoCache() =
|
||||
installCache(CacheControl.NoCache(null))
|
||||
|
||||
internal fun ApplicationCallPipeline.installCache(cacheControl: CacheControl) =
|
||||
install(CachingHeaders) {
|
||||
options { _, _ ->
|
||||
CachingOptions(cacheControl)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ApplicationCallPipeline.installNotarizedRoute(configure: NotarizedRoute.Config.() -> Unit = {}) =
|
||||
install(NotarizedRoute(), configure)
|
||||
38
src/main/kotlin/app/revanced/api/configuration/HTTP.kt
Normal file
38
src/main/kotlin/app/revanced/api/configuration/HTTP.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
package app.revanced.api.configuration
|
||||
|
||||
import app.revanced.api.configuration.repository.ConfigurationRepository
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.*
|
||||
import io.ktor.server.plugins.cors.routing.*
|
||||
import io.ktor.server.plugins.ratelimit.*
|
||||
import io.ktor.server.request.*
|
||||
import org.koin.ktor.ext.get
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
fun Application.configureHTTP() {
|
||||
val configurationRepository = get<ConfigurationRepository>()
|
||||
|
||||
install(CORS) {
|
||||
allowHost(
|
||||
host = configurationRepository.cors.host,
|
||||
subDomains = configurationRepository.cors.subDomains,
|
||||
)
|
||||
}
|
||||
|
||||
install(RateLimit) {
|
||||
fun rateLimit(name: String, block: RateLimitProviderConfig.() -> Unit) = register(RateLimitName(name)) {
|
||||
requestKey {
|
||||
it.request.uri + it.request.origin.remoteAddress
|
||||
}
|
||||
|
||||
block()
|
||||
}
|
||||
|
||||
rateLimit("weak") {
|
||||
rateLimiter(limit = 30, refillPeriod = 2.minutes)
|
||||
}
|
||||
rateLimit("strong") {
|
||||
rateLimiter(limit = 5, refillPeriod = 1.minutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/main/kotlin/app/revanced/api/configuration/Logging.kt
Normal file
16
src/main/kotlin/app/revanced/api/configuration/Logging.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package app.revanced.api.configuration
|
||||
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.callloging.*
|
||||
import io.ktor.server.request.*
|
||||
|
||||
internal fun Application.configureLogging() {
|
||||
install(CallLogging) {
|
||||
format { call ->
|
||||
val status = call.response.status()
|
||||
val httpMethod = call.request.httpMethod.value
|
||||
val uri = call.request.uri
|
||||
"$status $httpMethod $uri"
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/main/kotlin/app/revanced/api/configuration/OpenAPI.kt
Normal file
51
src/main/kotlin/app/revanced/api/configuration/OpenAPI.kt
Normal file
@@ -0,0 +1,51 @@
|
||||
package app.revanced.api.configuration
|
||||
|
||||
import app.revanced.api.command.applicationVersion
|
||||
import app.revanced.api.configuration.repository.ConfigurationRepository
|
||||
import io.bkbn.kompendium.core.plugin.NotarizedApplication
|
||||
import io.bkbn.kompendium.json.schema.KotlinXSchemaConfigurator
|
||||
import io.bkbn.kompendium.oas.OpenApiSpec
|
||||
import io.bkbn.kompendium.oas.component.Components
|
||||
import io.bkbn.kompendium.oas.info.Contact
|
||||
import io.bkbn.kompendium.oas.info.Info
|
||||
import io.bkbn.kompendium.oas.info.License
|
||||
import io.bkbn.kompendium.oas.security.BearerAuth
|
||||
import io.bkbn.kompendium.oas.server.Server
|
||||
import io.ktor.server.application.*
|
||||
import org.koin.ktor.ext.get
|
||||
import java.net.URI
|
||||
|
||||
internal fun Application.configureOpenAPI() {
|
||||
val configurationRepository = get<ConfigurationRepository>()
|
||||
|
||||
install(NotarizedApplication()) {
|
||||
spec = OpenApiSpec(
|
||||
info = Info(
|
||||
title = "ReVanced API",
|
||||
version = applicationVersion,
|
||||
description = "API server for ReVanced.",
|
||||
contact = Contact(
|
||||
name = "ReVanced",
|
||||
url = URI("https://revanced.app"),
|
||||
email = "contact@revanced.app",
|
||||
),
|
||||
license = License(
|
||||
name = "AGPLv3",
|
||||
url = URI("https://github.com/ReVanced/revanced-api/blob/main/LICENSE"),
|
||||
),
|
||||
),
|
||||
components = Components(
|
||||
securitySchemes = mutableMapOf(
|
||||
"bearer" to BearerAuth(),
|
||||
),
|
||||
),
|
||||
).apply {
|
||||
servers += Server(
|
||||
url = URI(configurationRepository.endpoint),
|
||||
description = "ReVanced API server",
|
||||
)
|
||||
}
|
||||
|
||||
schemaConfigurator = KotlinXSchemaConfigurator()
|
||||
}
|
||||
}
|
||||
31
src/main/kotlin/app/revanced/api/configuration/Routing.kt
Normal file
31
src/main/kotlin/app/revanced/api/configuration/Routing.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package app.revanced.api.configuration
|
||||
|
||||
import app.revanced.api.configuration.repository.ConfigurationRepository
|
||||
import app.revanced.api.configuration.routes.announcementsRoute
|
||||
import app.revanced.api.configuration.routes.oldApiRoute
|
||||
import app.revanced.api.configuration.routes.patchesRoute
|
||||
import app.revanced.api.configuration.routes.rootRoute
|
||||
import io.bkbn.kompendium.core.routes.redoc
|
||||
import io.bkbn.kompendium.core.routes.swagger
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import org.koin.ktor.ext.get as koinGet
|
||||
|
||||
internal fun Application.configureRouting() = routing {
|
||||
val configuration = koinGet<ConfigurationRepository>()
|
||||
|
||||
installCache(5.minutes)
|
||||
|
||||
route("/v${configuration.apiVersion}") {
|
||||
patchesRoute()
|
||||
announcementsRoute()
|
||||
rootRoute()
|
||||
}
|
||||
|
||||
swagger(pageTitle = "ReVanced API", path = "/")
|
||||
redoc(pageTitle = "ReVanced API", path = "/redoc")
|
||||
|
||||
// TODO: Remove, once migration period from v2 API is over (In 1-2 years).
|
||||
oldApiRoute()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package app.revanced.api.configuration
|
||||
|
||||
import app.revanced.api.configuration.services.AuthService
|
||||
import io.ktor.server.application.*
|
||||
import org.koin.ktor.ext.get
|
||||
|
||||
fun Application.configureSecurity() {
|
||||
get<AuthService>().configureSecurity(this)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package app.revanced.api.configuration
|
||||
|
||||
import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.contentnegotiation.*
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNamingStrategy
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun Application.configureSerialization() {
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json {
|
||||
serializersModule = KompendiumSerializersModule.module
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package app.revanced.api.configuration.repository
|
||||
|
||||
import app.revanced.api.configuration.schema.APIAnnouncement
|
||||
import app.revanced.api.configuration.schema.APIResponseAnnouncement
|
||||
import app.revanced.api.configuration.schema.APIResponseAnnouncementId
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.datetime.*
|
||||
import org.jetbrains.exposed.dao.IntEntity
|
||||
import org.jetbrains.exposed.dao.IntEntityClass
|
||||
import org.jetbrains.exposed.dao.id.EntityID
|
||||
import org.jetbrains.exposed.dao.id.IntIdTable
|
||||
import org.jetbrains.exposed.sql.*
|
||||
import org.jetbrains.exposed.sql.kotlin.datetime.CurrentDateTime
|
||||
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
|
||||
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
|
||||
import org.jetbrains.exposed.sql.transactions.experimental.suspendedTransactionAsync
|
||||
|
||||
internal class AnnouncementRepository {
|
||||
// This is better than doing a maxByOrNull { it.id }.
|
||||
private var latestAnnouncement: Announcement? = null
|
||||
private val latestAnnouncementByChannel = mutableMapOf<String, Announcement>()
|
||||
|
||||
private fun updateLatestAnnouncement(new: Announcement) {
|
||||
if (latestAnnouncement?.id?.value == new.id.value) {
|
||||
latestAnnouncement = new
|
||||
latestAnnouncementByChannel[new.channel ?: return] = new
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
runBlocking {
|
||||
transaction {
|
||||
SchemaUtils.create(Announcements, Attachments)
|
||||
|
||||
// Initialize the latest announcement.
|
||||
latestAnnouncement = Announcement.all().onEach {
|
||||
latestAnnouncementByChannel[it.channel ?: return@onEach] = it
|
||||
}.maxByOrNull { it.id } ?: return@transaction
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun all() = transaction {
|
||||
Announcement.all().map { it.toApi() }
|
||||
}
|
||||
|
||||
suspend fun all(channel: String) = transaction {
|
||||
Announcement.find { Announcements.channel eq channel }.map { it.toApi() }
|
||||
}
|
||||
|
||||
suspend fun delete(id: Int) = transaction {
|
||||
val announcement = Announcement.findById(id) ?: return@transaction
|
||||
|
||||
announcement.delete()
|
||||
|
||||
// In case the latest announcement was deleted, query the new latest announcement again.
|
||||
if (latestAnnouncement?.id?.value == id) {
|
||||
latestAnnouncement = Announcement.all().maxByOrNull { it.id }
|
||||
|
||||
// If no latest announcement was found, remove it from the channel map.
|
||||
if (latestAnnouncement == null) {
|
||||
latestAnnouncementByChannel.remove(announcement.channel)
|
||||
} else {
|
||||
latestAnnouncementByChannel[latestAnnouncement!!.channel ?: return@transaction] = latestAnnouncement!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun latest() = latestAnnouncement?.toApi()
|
||||
|
||||
fun latest(channel: String) = latestAnnouncementByChannel[channel]?.toApi()
|
||||
|
||||
fun latestId() = latest()?.id?.toApi()
|
||||
|
||||
fun latestId(channel: String) = latest(channel)?.id?.toApi()
|
||||
|
||||
suspend fun archive(
|
||||
id: Int,
|
||||
archivedAt: LocalDateTime?,
|
||||
) = transaction {
|
||||
Announcement.findByIdAndUpdate(id) {
|
||||
it.archivedAt = archivedAt ?: java.time.LocalDateTime.now().toKotlinLocalDateTime()
|
||||
}?.also(::updateLatestAnnouncement)
|
||||
}
|
||||
|
||||
suspend fun unarchive(id: Int) = transaction {
|
||||
Announcement.findByIdAndUpdate(id) {
|
||||
it.archivedAt = null
|
||||
}?.also(::updateLatestAnnouncement)
|
||||
}
|
||||
|
||||
suspend fun new(new: APIAnnouncement) = transaction {
|
||||
Announcement.new {
|
||||
author = new.author
|
||||
title = new.title
|
||||
content = new.content
|
||||
channel = new.channel
|
||||
archivedAt = new.archivedAt
|
||||
level = new.level
|
||||
}.also { newAnnouncement ->
|
||||
new.attachmentUrls.map { newUrl ->
|
||||
suspendedTransactionAsync {
|
||||
Attachment.new {
|
||||
url = newUrl
|
||||
announcement = newAnnouncement
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}.also(::updateLatestAnnouncement)
|
||||
}
|
||||
|
||||
suspend fun update(id: Int, new: APIAnnouncement) = transaction {
|
||||
Announcement.findByIdAndUpdate(id) {
|
||||
it.author = new.author
|
||||
it.title = new.title
|
||||
it.content = new.content
|
||||
it.channel = new.channel
|
||||
it.archivedAt = new.archivedAt
|
||||
it.level = new.level
|
||||
}?.also { newAnnouncement ->
|
||||
newAnnouncement.attachments.map {
|
||||
suspendedTransactionAsync {
|
||||
it.delete()
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
new.attachmentUrls.map { newUrl ->
|
||||
suspendedTransactionAsync {
|
||||
Attachment.new {
|
||||
url = newUrl
|
||||
announcement = newAnnouncement
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}?.also(::updateLatestAnnouncement)
|
||||
}
|
||||
|
||||
private suspend fun <T> transaction(statement: suspend Transaction.() -> T) =
|
||||
newSuspendedTransaction(Dispatchers.IO, statement = statement)
|
||||
|
||||
private object Announcements : IntIdTable() {
|
||||
val author = varchar("author", 32).nullable()
|
||||
val title = varchar("title", 64)
|
||||
val content = text("content").nullable()
|
||||
val channel = varchar("channel", 16).nullable()
|
||||
val createdAt = datetime("createdAt").defaultExpression(CurrentDateTime)
|
||||
val archivedAt = datetime("archivedAt").nullable()
|
||||
val level = integer("level")
|
||||
}
|
||||
|
||||
private object Attachments : IntIdTable() {
|
||||
val url = varchar("url", 256)
|
||||
val announcement = reference("announcement", Announcements, onDelete = ReferenceOption.CASCADE)
|
||||
}
|
||||
|
||||
class Announcement(id: EntityID<Int>) : IntEntity(id) {
|
||||
companion object : IntEntityClass<Announcement>(Announcements)
|
||||
|
||||
var author by Announcements.author
|
||||
var title by Announcements.title
|
||||
var content by Announcements.content
|
||||
val attachments by Attachment referrersOn Attachments.announcement
|
||||
var channel by Announcements.channel
|
||||
var createdAt by Announcements.createdAt
|
||||
var archivedAt by Announcements.archivedAt
|
||||
var level by Announcements.level
|
||||
}
|
||||
|
||||
class Attachment(id: EntityID<Int>) : IntEntity(id) {
|
||||
companion object : IntEntityClass<Attachment>(Attachments)
|
||||
|
||||
var url by Attachments.url
|
||||
var announcement by Announcement referencedOn Attachments.announcement
|
||||
}
|
||||
|
||||
private fun Announcement.toApi() = APIResponseAnnouncement(
|
||||
id.value,
|
||||
author,
|
||||
title,
|
||||
content,
|
||||
attachments.map { it.url },
|
||||
channel,
|
||||
createdAt,
|
||||
archivedAt,
|
||||
level,
|
||||
)
|
||||
|
||||
private fun Int.toApi() = APIResponseAnnouncementId(this)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package app.revanced.api.configuration.repository
|
||||
|
||||
import io.ktor.client.*
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
|
||||
/**
|
||||
* The backend of the API used to get data.
|
||||
*
|
||||
* @param client The HTTP client to use for requests.
|
||||
*/
|
||||
abstract class BackendRepository internal constructor(
|
||||
protected val client: HttpClient,
|
||||
) {
|
||||
/**
|
||||
* A user.
|
||||
*
|
||||
* @property name The name of the user.
|
||||
* @property avatarUrl The URL to the avatar of the user.
|
||||
* @property url The URL to the profile of the user.
|
||||
*/
|
||||
interface BackendUser {
|
||||
val name: String
|
||||
val avatarUrl: String
|
||||
val url: String
|
||||
}
|
||||
|
||||
/**
|
||||
* An organization.
|
||||
*
|
||||
* @property members The members of the organization.
|
||||
*/
|
||||
class BackendOrganization(
|
||||
// Using a list instead of a set because set semantics are unnecessary here.
|
||||
val members: List<BackendMember>,
|
||||
) {
|
||||
/**
|
||||
* A member of an organization.
|
||||
*
|
||||
* @property name The name of the member.
|
||||
* @property avatarUrl The URL to the avatar of the member.
|
||||
* @property url The URL to the profile of the member.
|
||||
* @property bio The bio of the member.
|
||||
* @property gpgKeys The GPG key of the member.
|
||||
*/
|
||||
class BackendMember(
|
||||
override val name: String,
|
||||
override val avatarUrl: String,
|
||||
override val url: String,
|
||||
val bio: String?,
|
||||
val gpgKeys: GpgKeys,
|
||||
) : BackendUser {
|
||||
/**
|
||||
* The GPG keys of a member.
|
||||
*
|
||||
* @property ids The IDs of the GPG keys.
|
||||
* @property url The URL to the GPG master key.
|
||||
*/
|
||||
class GpgKeys(
|
||||
// Using a list instead of a set because set semantics are unnecessary here.
|
||||
val ids: List<String>,
|
||||
val url: String,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A repository of an organization.
|
||||
*
|
||||
* @property contributors The contributors of the repository.
|
||||
*/
|
||||
class BackendRepository(
|
||||
// Using a list instead of a set because set semantics are unnecessary here.
|
||||
val contributors: List<BackendContributor>,
|
||||
) {
|
||||
/**
|
||||
* A contributor of a repository.
|
||||
*
|
||||
* @property name The name of the contributor.
|
||||
* @property avatarUrl The URL to the avatar of the contributor.
|
||||
* @property url The URL to the profile of the contributor.
|
||||
* @property contributions The number of contributions of the contributor.
|
||||
*/
|
||||
class BackendContributor(
|
||||
override val name: String,
|
||||
override val avatarUrl: String,
|
||||
override val url: String,
|
||||
val contributions: Int,
|
||||
) : BackendUser
|
||||
|
||||
/**
|
||||
* A release of a repository.
|
||||
*
|
||||
* @property tag The tag of the release.
|
||||
* @property assets The assets of the release.
|
||||
* @property createdAt The date and time the release was created.
|
||||
* @property releaseNote The release note of the release.
|
||||
*/
|
||||
class BackendRelease(
|
||||
val tag: String,
|
||||
val releaseNote: String,
|
||||
val createdAt: LocalDateTime,
|
||||
// Using a list instead of a set because set semantics are unnecessary here.
|
||||
val assets: List<BackendAsset>,
|
||||
) {
|
||||
companion object {
|
||||
fun List<BackendAsset>.first(assetRegex: Regex) = first { assetRegex.containsMatchIn(it.name) }
|
||||
}
|
||||
|
||||
/**
|
||||
* An asset of a release.
|
||||
*
|
||||
* @property name The name of the asset.
|
||||
* @property downloadUrl The URL to download the asset.
|
||||
*/
|
||||
class BackendAsset(
|
||||
val name: String,
|
||||
val downloadUrl: String,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rate limit of the backend.
|
||||
*
|
||||
* @property limit The limit of the rate limit.
|
||||
* @property remaining The remaining requests of the rate limit.
|
||||
* @property reset The date and time the rate limit resets.
|
||||
*/
|
||||
class BackendRateLimit(
|
||||
val limit: Int,
|
||||
val remaining: Int,
|
||||
val reset: LocalDateTime,
|
||||
)
|
||||
|
||||
/**
|
||||
* Get a release of a repository.
|
||||
*
|
||||
* @param owner The owner of the repository.
|
||||
* @param repository The name of the repository.
|
||||
* @param tag The tag of the release. If null, the latest release is returned.
|
||||
* @return The release.
|
||||
*/
|
||||
abstract suspend fun release(
|
||||
owner: String,
|
||||
repository: String,
|
||||
tag: String? = null,
|
||||
): BackendOrganization.BackendRepository.BackendRelease
|
||||
|
||||
/**
|
||||
* Get the contributors of a repository.
|
||||
*
|
||||
* @param owner The owner of the repository.
|
||||
* @param repository The name of the repository.
|
||||
* @return The contributors.
|
||||
*/
|
||||
abstract suspend fun contributors(owner: String, repository: String): List<BackendOrganization.BackendRepository.BackendContributor>
|
||||
|
||||
/**
|
||||
* Get the members of an organization.
|
||||
*
|
||||
* @param organization The name of the organization.
|
||||
* @return The members.
|
||||
*/
|
||||
abstract suspend fun members(organization: String): List<BackendOrganization.BackendMember>
|
||||
|
||||
/**
|
||||
* Get the rate limit of the backend.
|
||||
*
|
||||
* @return The rate limit.
|
||||
*/
|
||||
abstract suspend fun rateLimit(): BackendRateLimit?
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package app.revanced.api.configuration.repository
|
||||
|
||||
import app.revanced.api.configuration.services.PatchesService
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The repository storing the configuration for the API.
|
||||
*
|
||||
* @property organization The API backends organization name where the repositories for the patches and integrations are.
|
||||
* @property patches The source of the patches.
|
||||
* @property integrations The source of the integrations.
|
||||
* @property contributorsRepositoryNames The names of the repositories to get contributors from.
|
||||
* @property apiVersion The version to use for the API.
|
||||
* @property cors The CORS configuration.
|
||||
* @property endpoint The endpoint of the API.
|
||||
*/
|
||||
@Serializable
|
||||
internal class ConfigurationRepository(
|
||||
val organization: String,
|
||||
val patches: AssetConfiguration,
|
||||
val integrations: AssetConfiguration,
|
||||
@SerialName("contributors-repositories")
|
||||
val contributorsRepositoryNames: Set<String>,
|
||||
@SerialName("api-version")
|
||||
val apiVersion: Int = 1,
|
||||
val cors: Cors,
|
||||
val endpoint: String,
|
||||
) {
|
||||
/**
|
||||
* An asset configuration.
|
||||
*
|
||||
* [PatchesService] uses [BackendRepository] to get assets from its releases.
|
||||
* A release contains multiple assets.
|
||||
*
|
||||
* This configuration is used in [ConfigurationRepository]
|
||||
* to determine which release assets from repositories to get and to verify them.
|
||||
*
|
||||
* @property repository The repository in which releases are made to get an asset.
|
||||
* @property assetRegex The regex matching the asset name.
|
||||
* @property signatureAssetRegex The regex matching the signature asset name to verify the asset.
|
||||
* @property publicKeyFile The public key file to verify the signature of the asset.
|
||||
*/
|
||||
@Serializable
|
||||
internal class AssetConfiguration(
|
||||
val repository: String,
|
||||
@Serializable(with = RegexSerializer::class)
|
||||
@SerialName("asset-regex")
|
||||
val assetRegex: Regex,
|
||||
@Serializable(with = RegexSerializer::class)
|
||||
@SerialName("signature-asset-regex")
|
||||
val signatureAssetRegex: Regex,
|
||||
@Serializable(with = FileSerializer::class)
|
||||
@SerialName("public-key-file")
|
||||
val publicKeyFile: File,
|
||||
)
|
||||
|
||||
/**
|
||||
* The CORS configuration.
|
||||
*
|
||||
* @property host The host of the API to configure CORS.
|
||||
* @property subDomains The subdomains to allow for CORS.
|
||||
*/
|
||||
@Serializable
|
||||
internal class Cors(
|
||||
val host: String,
|
||||
@SerialName("sub-domains")
|
||||
val subDomains: List<String>,
|
||||
)
|
||||
}
|
||||
|
||||
private object RegexSerializer : KSerializer<Regex> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Regex", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Regex) = encoder.encodeString(value.pattern)
|
||||
|
||||
override fun deserialize(decoder: Decoder) = Regex(decoder.decodeString())
|
||||
}
|
||||
|
||||
private object FileSerializer : KSerializer<File> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("File", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: File) = encoder.encodeString(value.path)
|
||||
|
||||
override fun deserialize(decoder: Decoder) = File(decoder.decodeString())
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package app.revanced.api.configuration.repository
|
||||
|
||||
import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendMember
|
||||
import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendContributor
|
||||
import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendRelease
|
||||
import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendRelease.BackendAsset
|
||||
import app.revanced.api.configuration.repository.GitHubOrganization.GitHubRepository.GitHubContributor
|
||||
import app.revanced.api.configuration.repository.GitHubOrganization.GitHubRepository.GitHubRelease
|
||||
import app.revanced.api.configuration.repository.Organization.Repository.Contributors
|
||||
import app.revanced.api.configuration.repository.Organization.Repository.Releases
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.plugins.resources.*
|
||||
import io.ktor.resources.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class GitHubBackendRepository(client: HttpClient) : BackendRepository(client) {
|
||||
override suspend fun release(
|
||||
owner: String,
|
||||
repository: String,
|
||||
tag: String?,
|
||||
): BackendRelease {
|
||||
val release: GitHubRelease = if (tag != null) {
|
||||
client.get(Releases.Tag(owner, repository, tag)).body()
|
||||
} else {
|
||||
client.get(Releases.Latest(owner, repository)).body()
|
||||
}
|
||||
|
||||
return BackendRelease(
|
||||
tag = release.tagName,
|
||||
releaseNote = release.body,
|
||||
createdAt = release.createdAt.toLocalDateTime(TimeZone.UTC),
|
||||
assets = release.assets.map {
|
||||
BackendAsset(
|
||||
name = it.name,
|
||||
downloadUrl = it.browserDownloadUrl,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun contributors(
|
||||
owner: String,
|
||||
repository: String,
|
||||
): List<BackendContributor> {
|
||||
val contributors: List<GitHubContributor> = client.get(
|
||||
Contributors(
|
||||
owner,
|
||||
repository,
|
||||
),
|
||||
).body()
|
||||
|
||||
return contributors.map {
|
||||
BackendContributor(
|
||||
name = it.login,
|
||||
avatarUrl = it.avatarUrl,
|
||||
url = it.htmlUrl,
|
||||
contributions = it.contributions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun members(organization: String): List<BackendMember> {
|
||||
// Get the list of members of the organization.
|
||||
val members: List<GitHubOrganization.GitHubMember> = client.get(Organization.Members(organization)).body()
|
||||
|
||||
return coroutineScope {
|
||||
members.map { member ->
|
||||
async {
|
||||
awaitAll(
|
||||
async {
|
||||
// Get the user.
|
||||
client.get(User(member.login)).body<GitHubUser>()
|
||||
},
|
||||
async {
|
||||
// Get the GPG key of the user.
|
||||
client.get(User.GpgKeys(member.login)).body<List<GitHubUser.GitHubGpgKey>>()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}.awaitAll().map { responses ->
|
||||
val user = responses[0] as GitHubUser
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val gpgKeys = responses[1] as List<GitHubUser.GitHubGpgKey>
|
||||
|
||||
BackendMember(
|
||||
name = user.login,
|
||||
avatarUrl = user.avatarUrl,
|
||||
url = user.htmlUrl,
|
||||
bio = user.bio,
|
||||
gpgKeys =
|
||||
BackendMember.GpgKeys(
|
||||
ids = gpgKeys.map { it.keyId },
|
||||
url = "https://api.github.com/users/${user.login}.gpg",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun rateLimit(): BackendRateLimit {
|
||||
val rateLimit: GitHubRateLimit = client.get(RateLimit()).body()
|
||||
|
||||
return BackendRateLimit(
|
||||
limit = rateLimit.rate.limit,
|
||||
remaining = rateLimit.rate.remaining,
|
||||
reset = Instant.fromEpochSeconds(rateLimit.rate.reset).toLocalDateTime(TimeZone.UTC),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface IGitHubUser {
|
||||
val login: String
|
||||
val avatarUrl: String
|
||||
val htmlUrl: String
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class GitHubUser(
|
||||
override val login: String,
|
||||
override val avatarUrl: String,
|
||||
override val htmlUrl: String,
|
||||
val bio: String?,
|
||||
) : IGitHubUser {
|
||||
@Serializable
|
||||
class GitHubGpgKey(
|
||||
val keyId: String,
|
||||
)
|
||||
}
|
||||
|
||||
class GitHubOrganization {
|
||||
@Serializable
|
||||
class GitHubMember(
|
||||
override val login: String,
|
||||
override val avatarUrl: String,
|
||||
override val htmlUrl: String,
|
||||
) : IGitHubUser
|
||||
|
||||
class GitHubRepository {
|
||||
@Serializable
|
||||
class GitHubContributor(
|
||||
override val login: String,
|
||||
override val avatarUrl: String,
|
||||
override val htmlUrl: String,
|
||||
val contributions: Int,
|
||||
) : IGitHubUser
|
||||
|
||||
@Serializable
|
||||
class GitHubRelease(
|
||||
val tagName: String,
|
||||
// Using a list instead of a set because set semantics are unnecessary here.
|
||||
val assets: List<GitHubAsset>,
|
||||
val createdAt: Instant,
|
||||
val body: String,
|
||||
) {
|
||||
@Serializable
|
||||
class GitHubAsset(
|
||||
val name: String,
|
||||
val browserDownloadUrl: String,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class GitHubRateLimit(
|
||||
val rate: Rate,
|
||||
) {
|
||||
@Serializable
|
||||
class Rate(
|
||||
val limit: Int,
|
||||
val remaining: Int,
|
||||
val reset: Long,
|
||||
)
|
||||
}
|
||||
|
||||
@Resource("/users/{login}")
|
||||
class User(val login: String) {
|
||||
@Resource("/users/{login}/gpg_keys")
|
||||
class GpgKeys(val login: String)
|
||||
}
|
||||
|
||||
class Organization {
|
||||
@Resource("/orgs/{org}/members")
|
||||
class Members(val org: String)
|
||||
|
||||
class Repository {
|
||||
@Resource("/repos/{owner}/{repo}/contributors")
|
||||
class Contributors(val owner: String, val repo: String)
|
||||
|
||||
@Resource("/repos/{owner}/{repo}/releases")
|
||||
class Releases(val owner: String, val repo: String) {
|
||||
@Resource("/repos/{owner}/{repo}/releases/tags/{tag}")
|
||||
class Tag(val owner: String, val repo: String, val tag: String)
|
||||
|
||||
@Resource("/repos/{owner}/{repo}/releases/latest")
|
||||
class Latest(val owner: String, val repo: String)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Resource("/rate_limit")
|
||||
class RateLimit
|
||||
@@ -0,0 +1,390 @@
|
||||
package app.revanced.api.configuration.routes
|
||||
|
||||
import app.revanced.api.configuration.installCache
|
||||
import app.revanced.api.configuration.installNotarizedRoute
|
||||
import app.revanced.api.configuration.respondOrNotFound
|
||||
import app.revanced.api.configuration.schema.APIAnnouncement
|
||||
import app.revanced.api.configuration.schema.APIAnnouncementArchivedAt
|
||||
import app.revanced.api.configuration.schema.APIResponseAnnouncement
|
||||
import app.revanced.api.configuration.schema.APIResponseAnnouncementId
|
||||
import app.revanced.api.configuration.services.AnnouncementService
|
||||
import io.bkbn.kompendium.core.metadata.DeleteInfo
|
||||
import io.bkbn.kompendium.core.metadata.GetInfo
|
||||
import io.bkbn.kompendium.core.metadata.PatchInfo
|
||||
import io.bkbn.kompendium.core.metadata.PostInfo
|
||||
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
|
||||
import io.bkbn.kompendium.oas.payload.Parameter
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.plugins.ratelimit.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.util.*
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import org.koin.ktor.ext.get as koinGet
|
||||
|
||||
internal fun Route.announcementsRoute() = route("announcements") {
|
||||
val announcementService = koinGet<AnnouncementService>()
|
||||
|
||||
installCache(5.minutes)
|
||||
|
||||
installAnnouncementsRouteDocumentation()
|
||||
|
||||
rateLimit(RateLimitName("strong")) {
|
||||
get {
|
||||
call.respond(announcementService.all())
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit(RateLimitName("strong")) {
|
||||
route("{channel}/latest") {
|
||||
installLatestChannelAnnouncementRouteDocumentation()
|
||||
|
||||
get {
|
||||
val channel: String by call.parameters
|
||||
|
||||
call.respondOrNotFound(announcementService.latest(channel))
|
||||
}
|
||||
|
||||
route("id") {
|
||||
installLatestChannelAnnouncementIdRouteDocumentation()
|
||||
|
||||
get {
|
||||
val channel: String by call.parameters
|
||||
|
||||
call.respondOrNotFound(announcementService.latestId(channel))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit(RateLimitName("strong")) {
|
||||
route("{channel}") {
|
||||
installChannelAnnouncementsRouteDocumentation()
|
||||
|
||||
get {
|
||||
val channel: String by call.parameters
|
||||
|
||||
call.respond(announcementService.all(channel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit(RateLimitName("strong")) {
|
||||
route("latest") {
|
||||
installLatestAnnouncementRouteDocumentation()
|
||||
|
||||
get {
|
||||
call.respondOrNotFound(announcementService.latest())
|
||||
}
|
||||
|
||||
route("id") {
|
||||
installLatestAnnouncementIdRouteDocumentation()
|
||||
|
||||
get {
|
||||
call.respondOrNotFound(announcementService.latestId())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit(RateLimitName("strong")) {
|
||||
authenticate("jwt") {
|
||||
installAnnouncementRouteDocumentation()
|
||||
|
||||
post<APIAnnouncement> { announcement ->
|
||||
announcementService.new(announcement)
|
||||
}
|
||||
|
||||
route("{id}") {
|
||||
installAnnouncementIdRouteDocumentation()
|
||||
|
||||
patch<APIAnnouncement> { announcement ->
|
||||
val id: Int by call.parameters
|
||||
|
||||
announcementService.update(id, announcement)
|
||||
}
|
||||
|
||||
delete {
|
||||
val id: Int by call.parameters
|
||||
|
||||
announcementService.delete(id)
|
||||
}
|
||||
|
||||
route("archive") {
|
||||
installAnnouncementArchiveRouteDocumentation()
|
||||
|
||||
post {
|
||||
val id: Int by call.parameters
|
||||
val archivedAt = call.receiveNullable<APIAnnouncementArchivedAt>()?.archivedAt
|
||||
|
||||
announcementService.archive(id, archivedAt)
|
||||
}
|
||||
}
|
||||
|
||||
route("unarchive") {
|
||||
installAnnouncementUnarchiveRouteDocumentation()
|
||||
|
||||
post {
|
||||
val id: Int by call.parameters
|
||||
|
||||
announcementService.unarchive(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installAnnouncementRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
post = PostInfo.builder {
|
||||
description("Create a new announcement")
|
||||
summary("Create announcement")
|
||||
request {
|
||||
requestType<APIAnnouncement>()
|
||||
description("The new announcement")
|
||||
}
|
||||
response {
|
||||
description("When the announcement was created")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<Unit>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installLatestAnnouncementRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the latest announcement")
|
||||
summary("Get latest announcement")
|
||||
response {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The latest announcement")
|
||||
responseType<APIResponseAnnouncement>()
|
||||
}
|
||||
canRespond {
|
||||
responseCode(HttpStatusCode.NotFound)
|
||||
description("No announcement exists")
|
||||
responseType<Unit>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installLatestAnnouncementIdRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the id of the latest announcement")
|
||||
summary("Get id of latest announcement")
|
||||
response {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The id of the latest announcement")
|
||||
responseType<APIResponseAnnouncementId>()
|
||||
}
|
||||
canRespond {
|
||||
responseCode(HttpStatusCode.NotFound)
|
||||
description("No announcement exists")
|
||||
responseType<Unit>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installChannelAnnouncementsRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
parameters = listOf(
|
||||
Parameter(
|
||||
name = "channel",
|
||||
`in` = Parameter.Location.path,
|
||||
schema = TypeDefinition.STRING,
|
||||
description = "The channel to get the announcements from",
|
||||
required = true,
|
||||
),
|
||||
)
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the announcements from a channel")
|
||||
summary("Get announcements from channel")
|
||||
response {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The announcements in the channel")
|
||||
responseType<Set<APIResponseAnnouncement>>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installAnnouncementArchiveRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
parameters = listOf(
|
||||
Parameter(
|
||||
name = "id",
|
||||
`in` = Parameter.Location.path,
|
||||
schema = TypeDefinition.INT,
|
||||
description = "The id of the announcement to archive",
|
||||
required = true,
|
||||
),
|
||||
Parameter(
|
||||
name = "archivedAt",
|
||||
`in` = Parameter.Location.query,
|
||||
schema = TypeDefinition.STRING,
|
||||
description = "The date and time the announcement to be archived",
|
||||
required = false,
|
||||
),
|
||||
)
|
||||
|
||||
post = PostInfo.builder {
|
||||
description("Archive an announcement")
|
||||
summary("Archive announcement")
|
||||
response {
|
||||
description("When the announcement was archived")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<Unit>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installAnnouncementUnarchiveRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
parameters = listOf(
|
||||
Parameter(
|
||||
name = "id",
|
||||
`in` = Parameter.Location.path,
|
||||
schema = TypeDefinition.INT,
|
||||
description = "The id of the announcement to unarchive",
|
||||
required = true,
|
||||
),
|
||||
)
|
||||
|
||||
post = PostInfo.builder {
|
||||
description("Unarchive an announcement")
|
||||
summary("Unarchive announcement")
|
||||
response {
|
||||
description("When announcement was unarchived")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<Unit>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installAnnouncementIdRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
parameters = listOf(
|
||||
Parameter(
|
||||
name = "id",
|
||||
`in` = Parameter.Location.path,
|
||||
schema = TypeDefinition.INT,
|
||||
description = "The id of the announcement to update",
|
||||
required = true,
|
||||
),
|
||||
)
|
||||
|
||||
patch = PatchInfo.builder {
|
||||
description("Update an announcement")
|
||||
summary("Update announcement")
|
||||
request {
|
||||
requestType<APIAnnouncement>()
|
||||
description("The new announcement")
|
||||
}
|
||||
response {
|
||||
description("When announcement was updated")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<Unit>()
|
||||
}
|
||||
}
|
||||
|
||||
delete = DeleteInfo.builder {
|
||||
description("Delete an announcement")
|
||||
summary("Delete announcement")
|
||||
response {
|
||||
description("When the announcement was deleted")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<Unit>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installAnnouncementsRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the announcements")
|
||||
summary("Get announcements")
|
||||
response {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The announcements")
|
||||
responseType<Set<APIResponseAnnouncement>>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installLatestChannelAnnouncementRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
parameters = listOf(
|
||||
Parameter(
|
||||
name = "channel",
|
||||
`in` = Parameter.Location.path,
|
||||
schema = TypeDefinition.STRING,
|
||||
description = "The channel to get the latest announcement from",
|
||||
required = true,
|
||||
),
|
||||
)
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the latest announcement from a channel")
|
||||
summary("Get latest channel announcement")
|
||||
response {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The latest announcement in the channel")
|
||||
responseType<APIResponseAnnouncement>()
|
||||
}
|
||||
canRespond {
|
||||
responseCode(HttpStatusCode.NotFound)
|
||||
description("The channel does not exist")
|
||||
responseType<Unit>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installLatestChannelAnnouncementIdRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
parameters = listOf(
|
||||
Parameter(
|
||||
name = "channel",
|
||||
`in` = Parameter.Location.path,
|
||||
schema = TypeDefinition.STRING,
|
||||
description = "The channel to get the latest announcement id from",
|
||||
required = true,
|
||||
),
|
||||
)
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the id of the latest announcement from a channel")
|
||||
summary("Get id of latest announcement from channel")
|
||||
response {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The id of the latest announcement from the channel")
|
||||
responseType<APIResponseAnnouncementId>()
|
||||
}
|
||||
canRespond {
|
||||
responseCode(HttpStatusCode.NotFound)
|
||||
description("The channel does not exist")
|
||||
responseType<Unit>()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package app.revanced.api.configuration.routes
|
||||
|
||||
import app.revanced.api.configuration.installCache
|
||||
import app.revanced.api.configuration.installNoCache
|
||||
import app.revanced.api.configuration.installNotarizedRoute
|
||||
import app.revanced.api.configuration.respondOrNotFound
|
||||
import app.revanced.api.configuration.schema.APIContributable
|
||||
import app.revanced.api.configuration.schema.APIMember
|
||||
import app.revanced.api.configuration.schema.APIRateLimit
|
||||
import app.revanced.api.configuration.services.ApiService
|
||||
import app.revanced.api.configuration.services.AuthService
|
||||
import io.bkbn.kompendium.core.metadata.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.http.content.*
|
||||
import io.ktor.server.plugins.ratelimit.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import org.koin.ktor.ext.get as koinGet
|
||||
|
||||
internal fun Route.rootRoute() {
|
||||
val apiService = koinGet<ApiService>()
|
||||
val authService = koinGet<AuthService>()
|
||||
|
||||
rateLimit(RateLimitName("strong")) {
|
||||
authenticate("auth-digest") {
|
||||
route("token") {
|
||||
installTokenRouteDocumentation()
|
||||
|
||||
get {
|
||||
call.respond(authService.newToken())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route("contributors") {
|
||||
installCache(1.days)
|
||||
|
||||
installContributorsRouteDocumentation()
|
||||
|
||||
get {
|
||||
call.respond(apiService.contributors())
|
||||
}
|
||||
}
|
||||
|
||||
route("team") {
|
||||
installCache(1.days)
|
||||
|
||||
installTeamRouteDocumentation()
|
||||
|
||||
get {
|
||||
call.respond(apiService.team())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route("ping") {
|
||||
installNoCache()
|
||||
|
||||
installPingRouteDocumentation()
|
||||
|
||||
head {
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit(RateLimitName("weak")) {
|
||||
route("backend/rate_limit") {
|
||||
installRateLimitRouteDocumentation()
|
||||
|
||||
get {
|
||||
call.respondOrNotFound(apiService.rateLimit())
|
||||
}
|
||||
}
|
||||
|
||||
staticResources("/", "/app/revanced/api/static") {
|
||||
contentType { ContentType.Application.Json }
|
||||
extensions("json")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.installRateLimitRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("API")
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the rate limit of the backend")
|
||||
summary("Get rate limit of backend")
|
||||
response {
|
||||
description("The rate limit of the backend")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<APIRateLimit>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.installPingRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("API")
|
||||
|
||||
head = HeadInfo.builder {
|
||||
description("Ping the server")
|
||||
summary("Ping")
|
||||
response {
|
||||
description("The server is reachable")
|
||||
responseCode(HttpStatusCode.NoContent)
|
||||
responseType<Unit>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.installTeamRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("API")
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the list of team members")
|
||||
summary("Get team members")
|
||||
response {
|
||||
description("The list of team members")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<Set<APIMember>>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.installContributorsRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("API")
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the list of contributors")
|
||||
summary("Get contributors")
|
||||
response {
|
||||
description("The list of contributors")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<Set<APIContributable>>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.installTokenRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("API")
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get a new authorization token")
|
||||
summary("Get authorization token")
|
||||
response {
|
||||
description("The authorization token")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<String>()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package app.revanced.api.configuration.routes
|
||||
|
||||
import app.revanced.api.configuration.services.OldApiService
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.ratelimit.*
|
||||
import io.ktor.server.routing.*
|
||||
import org.koin.ktor.ext.get
|
||||
|
||||
internal fun Route.oldApiRoute() {
|
||||
val oldApiService = get<OldApiService>()
|
||||
|
||||
rateLimit(RateLimitName("weak")) {
|
||||
route(Regex("/(v2|tools|contributors).*")) {
|
||||
handle {
|
||||
oldApiService.proxy(call)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package app.revanced.api.configuration.routes
|
||||
|
||||
import app.revanced.api.configuration.installCache
|
||||
import app.revanced.api.configuration.installNotarizedRoute
|
||||
import app.revanced.api.configuration.schema.APIAssetPublicKeys
|
||||
import app.revanced.api.configuration.schema.APIRelease
|
||||
import app.revanced.api.configuration.schema.APIReleaseVersion
|
||||
import app.revanced.api.configuration.services.PatchesService
|
||||
import io.bkbn.kompendium.core.metadata.GetInfo
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.ratelimit.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import org.koin.ktor.ext.get as koinGet
|
||||
|
||||
internal fun Route.patchesRoute() = route("patches") {
|
||||
val patchesService = koinGet<PatchesService>()
|
||||
|
||||
route("latest") {
|
||||
installLatestPatchesRouteDocumentation()
|
||||
|
||||
rateLimit(RateLimitName("weak")) {
|
||||
get {
|
||||
call.respond(patchesService.latestRelease())
|
||||
}
|
||||
|
||||
route("version") {
|
||||
installLatestPatchesVersionRouteDocumentation()
|
||||
|
||||
get {
|
||||
call.respond(patchesService.latestVersion())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit(RateLimitName("strong")) {
|
||||
route("list") {
|
||||
installLatestPatchesListRouteDocumentation()
|
||||
|
||||
get {
|
||||
call.respondBytes(ContentType.Application.Json) { patchesService.list() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit(RateLimitName("strong")) {
|
||||
route("keys") {
|
||||
installCache(356.days)
|
||||
|
||||
installPatchesPublicKeyRouteDocumentation()
|
||||
|
||||
get {
|
||||
call.respond(patchesService.publicKeys())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.installLatestPatchesRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Patches")
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the latest patches release")
|
||||
summary("Get latest patches release")
|
||||
response {
|
||||
description("The latest patches release")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<APIRelease>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.installLatestPatchesVersionRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Patches")
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the latest patches release version")
|
||||
summary("Get latest patches release version")
|
||||
response {
|
||||
description("The latest patches release version")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<APIReleaseVersion>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.installLatestPatchesListRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Patches")
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the list of patches from the latest patches release")
|
||||
summary("Get list of patches from latest patches release")
|
||||
response {
|
||||
description("The list of patches")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<String>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.installPatchesPublicKeyRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Patches")
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the public keys for verifying patches and integrations assets")
|
||||
summary("Get patches and integrations public keys")
|
||||
response {
|
||||
description("The public keys")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<APIAssetPublicKeys>()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package app.revanced.api.configuration.schema
|
||||
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class APIRelease(
|
||||
val version: String,
|
||||
val createdAt: LocalDateTime,
|
||||
val description: String,
|
||||
// Using a list instead of a set because set semantics are unnecessary here.
|
||||
val assets: List<APIAsset>,
|
||||
)
|
||||
|
||||
interface APIUser {
|
||||
val name: String
|
||||
val avatarUrl: String
|
||||
val url: String
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class APIMember(
|
||||
override val name: String,
|
||||
override val avatarUrl: String,
|
||||
override val url: String,
|
||||
val gpgKey: APIGpgKey?,
|
||||
) : APIUser
|
||||
|
||||
@Serializable
|
||||
class APIGpgKey(
|
||||
val id: String,
|
||||
val url: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIContributor(
|
||||
override val name: String,
|
||||
override val avatarUrl: String,
|
||||
override val url: String,
|
||||
val contributions: Int,
|
||||
) : APIUser
|
||||
|
||||
@Serializable
|
||||
class APIContributable(
|
||||
val name: String,
|
||||
// Using a list instead of a set because set semantics are unnecessary here.
|
||||
val contributors: List<APIContributor>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIAsset(
|
||||
val downloadUrl: String,
|
||||
val signatureDownloadUrl: String,
|
||||
// TODO: Remove this eventually when integrations are merged into patches.
|
||||
val name: APIAssetName,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class APIAssetName {
|
||||
PATCHES,
|
||||
INTEGRATION,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class APIReleaseVersion(
|
||||
val version: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIAnnouncement(
|
||||
val author: String? = null,
|
||||
val title: String,
|
||||
val content: String? = null,
|
||||
// Using a list instead of a set because set semantics are unnecessary here.
|
||||
val attachmentUrls: List<String> = emptyList(),
|
||||
val channel: String? = null,
|
||||
val archivedAt: LocalDateTime? = null,
|
||||
val level: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIResponseAnnouncement(
|
||||
val id: Int,
|
||||
val author: String? = null,
|
||||
val title: String,
|
||||
val content: String? = null,
|
||||
// Using a list instead of a set because set semantics are unnecessary here.
|
||||
val attachmentUrls: List<String> = emptyList(),
|
||||
val channel: String? = null,
|
||||
val createdAt: LocalDateTime,
|
||||
val archivedAt: LocalDateTime? = null,
|
||||
val level: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIResponseAnnouncementId(
|
||||
val id: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIAnnouncementArchivedAt(
|
||||
val archivedAt: LocalDateTime,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIRateLimit(
|
||||
val limit: Int,
|
||||
val remaining: Int,
|
||||
val reset: LocalDateTime,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIAssetPublicKeys(
|
||||
val patchesPublicKey: String,
|
||||
val integrationsPublicKey: String,
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
package app.revanced.api.configuration.services
|
||||
|
||||
import app.revanced.api.configuration.repository.AnnouncementRepository
|
||||
import app.revanced.api.configuration.schema.APIAnnouncement
|
||||
import app.revanced.api.configuration.schema.APIResponseAnnouncementId
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
|
||||
internal class AnnouncementService(
|
||||
private val announcementRepository: AnnouncementRepository,
|
||||
) {
|
||||
fun latestId(channel: String): APIResponseAnnouncementId? = announcementRepository.latestId(channel)
|
||||
fun latestId(): APIResponseAnnouncementId? = announcementRepository.latestId()
|
||||
|
||||
fun latest(channel: String) = announcementRepository.latest(channel)
|
||||
fun latest() = announcementRepository.latest()
|
||||
|
||||
suspend fun all(channel: String) = announcementRepository.all(channel)
|
||||
suspend fun all() = announcementRepository.all()
|
||||
|
||||
suspend fun new(new: APIAnnouncement) {
|
||||
announcementRepository.new(new)
|
||||
}
|
||||
suspend fun archive(id: Int, archivedAt: LocalDateTime?) {
|
||||
announcementRepository.archive(id, archivedAt)
|
||||
}
|
||||
suspend fun unarchive(id: Int) {
|
||||
announcementRepository.unarchive(id)
|
||||
}
|
||||
suspend fun update(id: Int, new: APIAnnouncement) {
|
||||
announcementRepository.update(id, new)
|
||||
}
|
||||
suspend fun delete(id: Int) {
|
||||
announcementRepository.delete(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package app.revanced.api.configuration.services
|
||||
|
||||
import app.revanced.api.configuration.repository.BackendRepository
|
||||
import app.revanced.api.configuration.repository.ConfigurationRepository
|
||||
import app.revanced.api.configuration.schema.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal class ApiService(
|
||||
private val backendRepository: BackendRepository,
|
||||
private val configurationRepository: ConfigurationRepository,
|
||||
) {
|
||||
suspend fun contributors() = withContext(Dispatchers.IO) {
|
||||
configurationRepository.contributorsRepositoryNames.map {
|
||||
async {
|
||||
APIContributable(
|
||||
it,
|
||||
backendRepository.contributors(configurationRepository.organization, it).map {
|
||||
APIContributor(it.name, it.avatarUrl, it.url, it.contributions)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
suspend fun team() = backendRepository.members(configurationRepository.organization).map { member ->
|
||||
APIMember(
|
||||
member.name,
|
||||
member.avatarUrl,
|
||||
member.url,
|
||||
if (member.gpgKeys.ids.isNotEmpty()) {
|
||||
APIGpgKey(
|
||||
// Must choose one of the GPG keys, because it does not make sense to have multiple GPG keys for the API.
|
||||
member.gpgKeys.ids.first(),
|
||||
member.gpgKeys.url,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun rateLimit() = backendRepository.rateLimit()?.let {
|
||||
APIRateLimit(it.limit, it.remaining, it.reset)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package app.revanced.api.configuration.services
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.auth.jwt.*
|
||||
import java.util.*
|
||||
import kotlin.text.HexFormat
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
internal class AuthService private constructor(
|
||||
private val issuer: String,
|
||||
private val validityInMin: Int,
|
||||
private val jwtSecret: String,
|
||||
private val authSHA256Digest: ByteArray,
|
||||
) {
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
constructor(issuer: String, validityInMin: Int, jwtSecret: String, authSHA256DigestString: String) : this(
|
||||
issuer,
|
||||
validityInMin,
|
||||
jwtSecret,
|
||||
authSHA256DigestString.hexToByteArray(HexFormat.Default),
|
||||
)
|
||||
|
||||
val configureSecurity: Application.() -> Unit = {
|
||||
install(Authentication) {
|
||||
jwt("jwt") {
|
||||
realm = "ReVanced"
|
||||
|
||||
verifier(JWT.require(Algorithm.HMAC256(jwtSecret)).withIssuer(issuer).build())
|
||||
}
|
||||
|
||||
digest("auth-digest") {
|
||||
realm = "ReVanced"
|
||||
algorithmName = "SHA-256"
|
||||
|
||||
digestProvider { _, _ ->
|
||||
authSHA256Digest
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun newToken(): String = JWT.create()
|
||||
.withIssuer(issuer)
|
||||
.withExpiresAt(Date(System.currentTimeMillis() + validityInMin.minutes.inWholeMilliseconds))
|
||||
.sign(Algorithm.HMAC256(jwtSecret))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package app.revanced.api.configuration.services
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.content.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.util.*
|
||||
import io.ktor.utils.io.*
|
||||
|
||||
internal class OldApiService(private val client: HttpClient) {
|
||||
@OptIn(InternalAPI::class)
|
||||
suspend fun proxy(call: ApplicationCall) {
|
||||
val channel = call.request.receiveChannel()
|
||||
val size = channel.availableForRead
|
||||
val byteArray = ByteArray(size)
|
||||
channel.readFully(byteArray)
|
||||
|
||||
val response: HttpResponse = client.request(call.request.uri) {
|
||||
method = call.request.httpMethod
|
||||
|
||||
headers {
|
||||
appendAll(
|
||||
call.request.headers.filter { key, _ ->
|
||||
!(
|
||||
key.equals(HttpHeaders.ContentType, ignoreCase = true) ||
|
||||
key.equals(HttpHeaders.ContentLength, ignoreCase = true) ||
|
||||
key.equals(HttpHeaders.Host, ignoreCase = true)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
when (call.request.httpMethod) {
|
||||
HttpMethod.Post,
|
||||
HttpMethod.Put,
|
||||
HttpMethod.Patch,
|
||||
HttpMethod.Delete,
|
||||
-> body = ByteArrayContent(byteArray, call.request.contentType())
|
||||
}
|
||||
}
|
||||
|
||||
val headers = response.headers
|
||||
|
||||
call.respond(object : OutgoingContent.WriteChannelContent() {
|
||||
override val contentLength: Long? = headers[HttpHeaders.ContentLength]?.toLong()
|
||||
override val contentType = headers[HttpHeaders.ContentType]?.let { ContentType.parse(it) }
|
||||
override val headers: Headers = Headers.build {
|
||||
appendAll(
|
||||
headers.filter { key, _ ->
|
||||
!key.equals(
|
||||
HttpHeaders.ContentType,
|
||||
ignoreCase = true,
|
||||
) &&
|
||||
!key.equals(HttpHeaders.ContentLength, ignoreCase = true)
|
||||
},
|
||||
)
|
||||
}
|
||||
override val status = response.status
|
||||
|
||||
override suspend fun writeTo(channel: ByteWriteChannel) {
|
||||
response.content.copyAndClose(channel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package app.revanced.api.configuration.services
|
||||
|
||||
import app.revanced.api.configuration.repository.BackendRepository
|
||||
import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendRelease.Companion.first
|
||||
import app.revanced.api.configuration.repository.ConfigurationRepository
|
||||
import app.revanced.api.configuration.schema.*
|
||||
import app.revanced.library.PatchUtils
|
||||
import app.revanced.patcher.PatchBundleLoader
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
import io.ktor.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.URL
|
||||
|
||||
internal class PatchesService(
|
||||
private val signatureService: SignatureService,
|
||||
private val backendRepository: BackendRepository,
|
||||
private val configurationRepository: ConfigurationRepository,
|
||||
) {
|
||||
suspend fun latestRelease(): APIRelease {
|
||||
val patchesRelease = backendRepository.release(
|
||||
configurationRepository.organization,
|
||||
configurationRepository.patches.repository,
|
||||
)
|
||||
|
||||
val integrationsRelease = backendRepository.release(
|
||||
configurationRepository.organization,
|
||||
configurationRepository.integrations.repository,
|
||||
)
|
||||
|
||||
fun ConfigurationRepository.AssetConfiguration.asset(
|
||||
release: BackendRepository.BackendOrganization.BackendRepository.BackendRelease,
|
||||
assetName: APIAssetName,
|
||||
) = APIAsset(
|
||||
release.assets.first(assetRegex).downloadUrl,
|
||||
release.assets.first(signatureAssetRegex).downloadUrl,
|
||||
assetName,
|
||||
)
|
||||
|
||||
val patchesAsset = configurationRepository.patches.asset(
|
||||
patchesRelease,
|
||||
APIAssetName.PATCHES,
|
||||
)
|
||||
val integrationsAsset = configurationRepository.integrations.asset(
|
||||
integrationsRelease,
|
||||
APIAssetName.INTEGRATION,
|
||||
)
|
||||
|
||||
return APIRelease(
|
||||
patchesRelease.tag,
|
||||
patchesRelease.createdAt,
|
||||
patchesRelease.releaseNote,
|
||||
listOf(patchesAsset, integrationsAsset),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun latestVersion(): APIReleaseVersion {
|
||||
val patchesRelease = backendRepository.release(
|
||||
configurationRepository.organization,
|
||||
configurationRepository.patches.repository,
|
||||
)
|
||||
|
||||
return APIReleaseVersion(patchesRelease.tag)
|
||||
}
|
||||
|
||||
private val patchesListCache = Caffeine
|
||||
.newBuilder()
|
||||
.maximumSize(1)
|
||||
.build<String, ByteArray>()
|
||||
|
||||
suspend fun list(): ByteArray {
|
||||
val patchesRelease = backendRepository.release(
|
||||
configurationRepository.organization,
|
||||
configurationRepository.patches.repository,
|
||||
)
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
patchesListCache.get(patchesRelease.tag) {
|
||||
val patchesDownloadUrl = patchesRelease.assets
|
||||
.first(configurationRepository.patches.assetRegex).downloadUrl
|
||||
|
||||
val signatureDownloadUrl = patchesRelease.assets
|
||||
.first(configurationRepository.patches.signatureAssetRegex).downloadUrl
|
||||
|
||||
val patchesFile = kotlin.io.path.createTempFile().toFile().apply {
|
||||
outputStream().use { URL(patchesDownloadUrl).openStream().copyTo(it) }
|
||||
}
|
||||
|
||||
val patches = if (
|
||||
signatureService.verify(
|
||||
patchesFile,
|
||||
signatureDownloadUrl,
|
||||
configurationRepository.patches.publicKeyFile,
|
||||
)
|
||||
) {
|
||||
PatchBundleLoader.Jar(patchesFile)
|
||||
} else {
|
||||
// Use an empty set of patches if the signature is invalid.
|
||||
emptySet()
|
||||
}
|
||||
|
||||
patchesFile.delete()
|
||||
|
||||
ByteArrayOutputStream().use { stream ->
|
||||
PatchUtils.Json.serialize(patches, outputStream = stream)
|
||||
|
||||
stream.toByteArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun publicKeys(): APIAssetPublicKeys {
|
||||
fun publicKeyBase64(getAssetConfiguration: ConfigurationRepository.() -> ConfigurationRepository.AssetConfiguration) =
|
||||
configurationRepository.getAssetConfiguration().publicKeyFile.readBytes().encodeBase64()
|
||||
|
||||
return APIAssetPublicKeys(
|
||||
publicKeyBase64 { patches },
|
||||
publicKeyBase64 { integrations },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package app.revanced.api.configuration.services
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||
import org.bouncycastle.openpgp.*
|
||||
import org.bouncycastle.openpgp.operator.bc.BcKeyFingerprintCalculator
|
||||
import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.security.Security
|
||||
|
||||
internal class SignatureService {
|
||||
private val signatureCache = Caffeine
|
||||
.newBuilder()
|
||||
.maximumSize(2) // Assuming this is enough for patches and integrations.
|
||||
.build<ByteArray, Boolean>() // Hash -> Verified.
|
||||
|
||||
fun verify(
|
||||
file: File,
|
||||
signatureDownloadUrl: String,
|
||||
publicKeyFile: File,
|
||||
): Boolean {
|
||||
val fileBytes = file.readBytes()
|
||||
|
||||
return signatureCache.get(MessageDigest.getInstance("SHA-256").digest(fileBytes)) {
|
||||
verify(
|
||||
fileBytes = fileBytes,
|
||||
signatureInputStream = URL(signatureDownloadUrl).openStream(),
|
||||
publicKeyInputStream = publicKeyFile.inputStream(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun verify(
|
||||
fileBytes: ByteArray,
|
||||
signatureInputStream: InputStream,
|
||||
publicKeyInputStream: InputStream,
|
||||
) = getSignature(signatureInputStream).apply {
|
||||
init(BcPGPContentVerifierBuilderProvider(), getPublicKey(publicKeyInputStream))
|
||||
update(fileBytes)
|
||||
}.verify()
|
||||
|
||||
private fun getPublicKey(publicKeyInputStream: InputStream): PGPPublicKey {
|
||||
val decoderStream = PGPUtil.getDecoderStream(publicKeyInputStream)
|
||||
|
||||
PGPPublicKeyRingCollection(decoderStream, BcKeyFingerprintCalculator()).forEach { keyRing ->
|
||||
keyRing.publicKeys.forEach { publicKey ->
|
||||
if (publicKey.isEncryptionKey) {
|
||||
return publicKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw IllegalArgumentException("Can't find encryption key in key ring.")
|
||||
}
|
||||
|
||||
private fun getSignature(inputStream: InputStream): PGPSignature {
|
||||
val decoderStream = PGPUtil.getDecoderStream(inputStream)
|
||||
val pgpObjectFactory = PGPObjectFactory(decoderStream, BcKeyFingerprintCalculator())
|
||||
val signatureList = pgpObjectFactory.nextObject() as PGPSignatureList
|
||||
|
||||
return signatureList.first()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
init {
|
||||
Security.addProvider(BouncyCastleProvider())
|
||||
}
|
||||
}
|
||||
}
|
||||
83
src/main/resources/app/revanced/api/static/about.json
Normal file
83
src/main/resources/app/revanced/api/static/about.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "ReVanced",
|
||||
"about": "ReVanced was born out of Vanced's discontinuation and it is our goal to continue the legacy of what Vanced left behind. Thanks to ReVanced Patcher, it's possible to create long-lasting patches for nearly any Android app. ReVanced's patching system is designed to allow patches to work on new versions of the apps automatically with bare minimum maintenance.",
|
||||
"branding": {
|
||||
"logo": "https://raw.githubusercontent.com/ReVanced/revanced-branding/main/assets/revanced-logo/revanced-logo.svg"
|
||||
},
|
||||
"contact": {
|
||||
"email": "contact@revanced.app"
|
||||
},
|
||||
"socials": [
|
||||
{
|
||||
"name": "Website",
|
||||
"url": "https://revanced.app",
|
||||
"preferred": true
|
||||
},
|
||||
{
|
||||
"name": "GitHub",
|
||||
"url": "https://github.com/revanced"
|
||||
},
|
||||
{
|
||||
"name": "Twitter",
|
||||
"url": "https://twitter.com/revancedapp"
|
||||
},
|
||||
{
|
||||
"name": "Discord",
|
||||
"url": "https://revanced.app/discord",
|
||||
"preferred": true
|
||||
},
|
||||
{
|
||||
"name": "Reddit",
|
||||
"url": "https://www.reddit.com/r/revancedapp"
|
||||
},
|
||||
{
|
||||
"name": "Telegram",
|
||||
"url": "https://t.me/app_revanced"
|
||||
},
|
||||
{
|
||||
"name": "YouTube",
|
||||
"url": "https://www.youtube.com/@ReVanced"
|
||||
}
|
||||
],
|
||||
"donations": {
|
||||
"wallets": [
|
||||
{
|
||||
"network": "Bitcoin",
|
||||
"currency_code": "BTC",
|
||||
"address": "bc1q4x8j6mt27y5gv0q625t8wkr87ruy8fprpy4v3f"
|
||||
},
|
||||
{
|
||||
"network": "Dogecoin",
|
||||
"currency_code": "DOGE",
|
||||
"address": "D8GH73rNjudgi6bS2krrXWEsU9KShedLXp",
|
||||
"preferred": true
|
||||
},
|
||||
{
|
||||
"network": "Ethereum",
|
||||
"currency_code": "ETH",
|
||||
"address": "0x7ab4091e00363654bf84B34151225742cd92FCE5"
|
||||
},
|
||||
{
|
||||
"network": "Litecoin",
|
||||
"currency_code": "LTC",
|
||||
"address": "LbJi8EuoDcwaZvykcKmcrM74jpjde23qJ2"
|
||||
},
|
||||
{
|
||||
"network": "Monero",
|
||||
"currency_code": "XMR",
|
||||
"address": "46YwWDbZD6jVptuk5mLHsuAmh1BnUMSjSNYacozQQEraWSQ93nb2yYVRHoMR6PmFYWEHsLHg9tr1cH5M8Rtn7YaaGQPCjSh"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"name": "Open Collective",
|
||||
"url": "https://opencollective.com/revanced",
|
||||
"preferred": true
|
||||
},
|
||||
{
|
||||
"name": "GitHub Sponsors",
|
||||
"url": "https://github.com/sponsors/ReVanced"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
2
src/main/resources/app/revanced/api/static/robots.txt
Normal file
2
src/main/resources/app/revanced/api/static/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
1
src/main/resources/app/revanced/api/version.properties
Normal file
1
src/main/resources/app/revanced/api/version.properties
Normal file
@@ -0,0 +1 @@
|
||||
version=${projectVersion}
|
||||
10
src/main/resources/logback.xml
Normal file
10
src/main/resources/logback.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="info">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</root>
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user