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:
oSumAtrIX
2024-07-08 14:07:33 +02:00
108 changed files with 10621 additions and 5616 deletions

View 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

View 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)
}
}

View 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,
)
}
}

View 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)

View 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)
}
}
}

View 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"
}
}
}

View 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()
}
}

View 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()
}

View File

@@ -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)
}

View File

@@ -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
},
)
}
}

View File

@@ -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)
}

View File

@@ -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?
}

View File

@@ -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())
}

View File

@@ -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

View File

@@ -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>()
}
}
}

View File

@@ -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>()
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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>()
}
}
}

View File

@@ -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,
)

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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))
}

View File

@@ -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)
}
})
}
}

View File

@@ -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 },
)
}
}

View File

@@ -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())
}
}
}

View 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"
}
]
}
}

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -0,0 +1 @@
version=${projectVersion}

View 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>