Compare commits

..

No commits in common. "main" and "v1.4.0-dev.1" have entirely different histories.

32 changed files with 495 additions and 477 deletions

View File

@ -13,6 +13,3 @@ AUTH_SHA256_DIGEST=
JWT_SECRET=
JWT_ISSUER=
JWT_VALIDITY_IN_MIN=
# Logging level for the application
LOG_LEVEL=INFO

1
.gitignore vendored
View File

@ -41,6 +41,7 @@ persistence/
configuration.toml
docker-compose.yml
patches-public-key.asc
integrations-public-key.asc
node_modules/
static/
about.json

View File

@ -1,126 +1,3 @@
# [1.6.0](https://github.com/ReVanced/revanced-api/compare/v1.5.0...v1.6.0) (2025-02-04)
### Features
* Add status page link to about ([8a957cd](https://github.com/ReVanced/revanced-api/commit/8a957cd797e7e42f43670baaed60ac0d3543342f))
* Add support for prereleases ([c25bc8b](https://github.com/ReVanced/revanced-api/commit/c25bc8b4ba2bd4bf1708f19dc8bc228a7f54d548))
* Allow setting `Announcement.createdAt` when creating an announcement ([7f6e29d](https://github.com/ReVanced/revanced-api/commit/7f6e29de5205f63ac4aaea490c844b58e14000c8))
* Make some announcements schema fields nullable ([db22874](https://github.com/ReVanced/revanced-api/commit/db22874f063bae0c9e7f0c99a20cdf1b16addd89))
# [1.6.0-dev.3](https://github.com/ReVanced/revanced-api/compare/v1.6.0-dev.2...v1.6.0-dev.3) (2024-12-25)
### Features
* Add status page link to about ([8a957cd](https://github.com/ReVanced/revanced-api/commit/8a957cd797e7e42f43670baaed60ac0d3543342f))
* Add support for prereleases ([c25bc8b](https://github.com/ReVanced/revanced-api/commit/c25bc8b4ba2bd4bf1708f19dc8bc228a7f54d548))
# [1.6.0-dev.2](https://github.com/ReVanced/revanced-api/compare/v1.6.0-dev.1...v1.6.0-dev.2) (2024-12-20)
### Features
* Make some announcements schema fields nullable ([db22874](https://github.com/ReVanced/revanced-api/commit/db22874f063bae0c9e7f0c99a20cdf1b16addd89))
# [1.6.0-dev.1](https://github.com/ReVanced/revanced-api/compare/v1.5.0...v1.6.0-dev.1) (2024-11-23)
### Features
* Allow setting `Announcement.createdAt` when creating an announcement ([7f6e29d](https://github.com/ReVanced/revanced-api/commit/7f6e29de5205f63ac4aaea490c844b58e14000c8))
# [1.5.0](https://github.com/ReVanced/revanced-api/compare/v1.4.0...v1.5.0) (2024-11-06)
### Features
* Allow updating `createdAt` field for announcements ([58ba4cb](https://github.com/ReVanced/revanced-api/commit/58ba4cb11c789507826cd70ac548943a94da4223))
* Move spec url to versioned path ([e871b23](https://github.com/ReVanced/revanced-api/commit/e871b23210798723c34bce93c7567d8fbcf4e060))
* Simplify log pattern ([d5d9e04](https://github.com/ReVanced/revanced-api/commit/d5d9e04325fa93540be0438e7b51243e2aeeab3d))
# [1.5.0-dev.2](https://github.com/ReVanced/revanced-api/compare/v1.5.0-dev.1...v1.5.0-dev.2) (2024-11-06)
### Features
* Allow updating `createdAt` field for announcements ([58ba4cb](https://github.com/ReVanced/revanced-api/commit/58ba4cb11c789507826cd70ac548943a94da4223))
* Simplify log pattern ([d5d9e04](https://github.com/ReVanced/revanced-api/commit/d5d9e04325fa93540be0438e7b51243e2aeeab3d))
# [1.5.0-dev.1](https://github.com/ReVanced/revanced-api/compare/v1.4.0...v1.5.0-dev.1) (2024-11-06)
### Features
* Move spec url to versioned path ([e871b23](https://github.com/ReVanced/revanced-api/commit/e871b23210798723c34bce93c7567d8fbcf4e060))
* Simplify log pattern ([d5d9e04](https://github.com/ReVanced/revanced-api/commit/d5d9e04325fa93540be0438e7b51243e2aeeab3d))
# [1.5.0-dev.1](https://github.com/ReVanced/revanced-api/compare/v1.4.0...v1.5.0-dev.1) (2024-11-06)
### Features
* Move spec url to versioned path ([e871b23](https://github.com/ReVanced/revanced-api/commit/e871b23210798723c34bce93c7567d8fbcf4e060))
# [1.4.0](https://github.com/ReVanced/revanced-api/compare/v1.3.0...v1.4.0) (2024-11-06)
### Bug Fixes
* Add missing logging level environment variable to .env.example ([3b62120](https://github.com/ReVanced/revanced-api/commit/3b6212065a5cfb95c303b6d0551747ba1eb317f6))
* Use new patches file extension ([d42a3a3](https://github.com/ReVanced/revanced-api/commit/d42a3a393396a0f4e9085cda46e0af2c12b63cb1))
### Features
* Add URL and use friendly name for `APIContributable` ([a5498ab](https://github.com/ReVanced/revanced-api/commit/a5498aba2b99db89c28a65738cc58cc4c852c327))
* Allow versioning by arbitrary path string ([814d3c9](https://github.com/ReVanced/revanced-api/commit/814d3c946e31068e12e3886aa8beb3238ef126ae))
* Improve announcements API ([#192](https://github.com/ReVanced/revanced-api/issues/192)) ([56a00dd](https://github.com/ReVanced/revanced-api/commit/56a00ddb85f302d441f0b222a9902ea2c1c18897))
* Make backend configurable ([f91f3a6](https://github.com/ReVanced/revanced-api/commit/f91f3a65c5e07b5b58ccbff1d4b0a5ba9b15fc50))
* Remove "archived" query parameter ([8ad614e](https://github.com/ReVanced/revanced-api/commit/8ad614ef4fdaf45af87a3316ef4db7e7236fd64a))
* Remove deprecated routes and old API ([eca40a6](https://github.com/ReVanced/revanced-api/commit/eca40a69799240f7803aa8851eb3ee961937e4d6))
* Remove ReVanced Integrations ([f1c1092](https://github.com/ReVanced/revanced-api/commit/f1c10928ae3be1c6b1d675819755b3046fad70d8))
* Use tag name directly instead of ID ([fc40427](https://github.com/ReVanced/revanced-api/commit/fc40427fbaafb523045eb6f5285d90949b206b8b))
# [1.4.0-dev.6](https://github.com/ReVanced/revanced-api/compare/v1.4.0-dev.5...v1.4.0-dev.6) (2024-11-06)
### Features
* Allow versioning by arbitrary path string ([814d3c9](https://github.com/ReVanced/revanced-api/commit/814d3c946e31068e12e3886aa8beb3238ef126ae))
* Remove deprecated routes and old API ([eca40a6](https://github.com/ReVanced/revanced-api/commit/eca40a69799240f7803aa8851eb3ee961937e4d6))
# [1.4.0-dev.5](https://github.com/ReVanced/revanced-api/compare/v1.4.0-dev.4...v1.4.0-dev.5) (2024-11-05)
# [1.4.0-dev.4](https://github.com/ReVanced/revanced-api/compare/v1.4.0-dev.3...v1.4.0-dev.4) (2024-11-01)
### Features
* Remove "archived" query parameter ([8ad614e](https://github.com/ReVanced/revanced-api/commit/8ad614ef4fdaf45af87a3316ef4db7e7236fd64a))
* Use tag name directly instead of ID ([fc40427](https://github.com/ReVanced/revanced-api/commit/fc40427fbaafb523045eb6f5285d90949b206b8b))
# [1.4.0-dev.3](https://github.com/ReVanced/revanced-api/compare/v1.4.0-dev.2...v1.4.0-dev.3) (2024-11-01)
### Bug Fixes
* Use new patches file extension ([d42a3a3](https://github.com/ReVanced/revanced-api/commit/d42a3a393396a0f4e9085cda46e0af2c12b63cb1))
# [1.4.0-dev.2](https://github.com/ReVanced/revanced-api/compare/v1.4.0-dev.1...v1.4.0-dev.2) (2024-11-01)
### Bug Fixes
* Add missing logging level environment variable to .env.example ([3b62120](https://github.com/ReVanced/revanced-api/commit/3b6212065a5cfb95c303b6d0551747ba1eb317f6))
### Features
* Add URL and use friendly name for `APIContributable` ([a5498ab](https://github.com/ReVanced/revanced-api/commit/a5498aba2b99db89c28a65738cc58cc4c852c327))
* Make backend configurable ([f91f3a6](https://github.com/ReVanced/revanced-api/commit/f91f3a65c5e07b5b58ccbff1d4b0a5ba9b15fc50))
* Remove ReVanced Integrations ([f1c1092](https://github.com/ReVanced/revanced-api/commit/f1c10928ae3be1c6b1d675819755b3046fad70d8))
# [1.4.0-dev.1](https://github.com/ReVanced/revanced-api/compare/v1.3.0...v1.4.0-dev.1) (2024-11-01)

View File

@ -68,8 +68,7 @@ API server for ReVanced.
## ❓ About
ReVanced API is a server that is used as the backend for ReVanced.
ReVanced API acts as the data source for [ReVanced Website](https://github.com/ReVanced/revanced-website) and
powers [ReVanced Manager](https://github.com/ReVanced/revanced-manager)
ReVanced API acts as the data source for [ReVanced Website](https://github.com/ReVanced/revanced-website) and powers [ReVanced Manager](https://github.com/ReVanced/revanced-manager)
with updates and ReVanced Patches.
## 💪 Features
@ -78,9 +77,10 @@ Some of the features ReVanced API include:
- 📢 **Announcements**: Post and get announcements
- **About**: Get more information such as a description, ways to donate to,
and links of the hoster of ReVanced API
and links of the hoster of ReVanced API
- 🧩 **Patches**: Get the latest updates of ReVanced Patches, directly from ReVanced API
- 👥 **Contributors**: List all contributors involved in the project
- 🔄 **Backwards compatibility**: Proxy an old API for migration purposes and backwards compatibility
## 🚀 How to get started
@ -90,8 +90,7 @@ ReVanced API can be deployed as a Docker container or used standalone.
To deploy ReVanced API as a Docker container, you can use Docker Compose or Docker CLI.
The Docker image is published on GitHub Container registry,
so before you can pull the image, you need
to [authenticate to the Container registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry).
so before you can pull the image, you need to [authenticate to the Container registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry).
### 🗄️ Docker Compose
@ -115,6 +114,8 @@ to [authenticate to the Container registry](https://docs.github.com/en/packages/
-v $(pwd)/configuration.toml:/app/configuration.toml \
# Mount the patches public key
-v $(pwd)/patches-public-key.asc:/app/patches-public-key.asc \
# Mount the integrations public key
-v $(pwd)/integrations-public-key.asc:/app/integrations-public-key.asc \
# Mount the static folder
-v $(pwd)/static:/app/static \
# Mount the about.json file
@ -140,7 +141,7 @@ A Java Runtime Environment (JRE) must be installed.
1. [Download](https://github.com/ReVanced/revanced-api/releases/latest) ReVanced API to a folder
2. In the same folder, create an `.env` file using [.env.example](.env.example) as a template
3. In the same folder, create a `configuration.toml` file
using [configuration.example.toml](configuration.example.toml) as a template
using [configuration.example.toml](configuration.example.toml) as a template
4. In the same folder, create an `about.json` file using [about.example.json](about.example.json) as a template
5. Run `java -jar revanced-api.jar start` to start the server
@ -158,8 +159,7 @@ A Java Development Kit (JDK) and Git must be installed.
### 📙 Contributing
Thank you for considering contributing to ReVanced API. You can find the contribution
guidelines [here](CONTRIBUTING.md).
Thank you for considering contributing to ReVanced API. You can find the contribution guidelines [here](CONTRIBUTING.md).
### 🛠️ Building

View File

@ -5,7 +5,6 @@
"branding": {
"logo": "https://raw.githubusercontent.com/ReVanced/revanced-branding/main/assets/revanced-logo/revanced-logo.svg"
},
"status": "https://status.revanced.app",
"contact": {
"email": "contact@revanced.app"
},

View File

@ -1,29 +1,22 @@
api-version = "v1"
organization = "revanced"
patches = { repository = "revanced-patches", asset-regex = "jar$", signature-asset-regex = "asc$", public-key-file = "patches-public-key.asc", public-key-id = 0 }
integrations = { repository = "revanced-integrations", asset-regex = "apk$", signature-asset-regex = "asc$", public-key-file = "integrations-public-key.asc", public-key-id = 0 }
manager = { repository = "revanced-manager", asset-regex = "apk$" }
contributors-repositories = [
"revanced-patcher",
"revanced-patches",
"revanced-integrations",
"revanced-website",
"revanced-cli",
"revanced-manager",
]
api-version = 1
cors-allowed-hosts = [
"revanced.app",
"*.revanced.app"
]
endpoint = "https://api.revanced.app"
old-api-endpoint = "https://old-api.revanced.app"
static-files-path = "static/root"
versioned-static-files-path = "static/versioned"
backend-service-name = "GitHub"
about-json-file-path = "about.json"
organization = "revanced"
[patches]
repository = "revanced-patches"
asset-regex = "rvp$"
signature-asset-regex = "asc$"
public-key-file = "static/root/keys.asc"
public-key-id = 3897925568445097277
[manager]
repository = "revanced-manager"
asset-regex = "apk$"
[contributors-repositories]
revanced-patcher = "ReVanced Patcher"
revanced-patches = "ReVanced Patches"
revanced-website = "ReVanced Website"
revanced-cli = "ReVanced CLI"
revanced-manager = "ReVanced Manager"

View File

@ -7,6 +7,7 @@ services:
- /data/revanced-api/.env:/app/.env
- /data/revanced-api/configuration.toml:/app/configuration.toml
- /data/revanced-api/patches-public-key.asc:/app/patches-public-key.asc
- /data/revanced-api/integrations-public-key.asc:/app/integrations-public-key.asc
- /data/revanced-api/static:/app/static
- /data/revanced-api/about.json:/app/about.json
environment:

View File

@ -1,4 +1,4 @@
org.gradle.parallel = true
org.gradle.caching = true
kotlin.code.style = official
version = 1.6.0
version = 1.4.0-dev.1

View File

@ -1,6 +1,6 @@
[versions]
kompendium-core = "3.14.4"
kotlin = "2.0.20"
kotlin = "2.0.0"
logback = "1.5.6"
exposed = "0.52.0"
h2 = "2.2.224"
@ -10,8 +10,8 @@ ktor = "2.3.7"
ktoml = "0.5.2"
picocli = "4.7.6"
datetime = "0.6.0"
revanced-patcher = "21.0.0"
revanced-library = "3.0.2"
revanced-patcher = "20.0.0"
revanced-library = "3.0.1-dev.1"
caffeine = "3.1.8"
bouncy-castle = "1.78.1"

View File

@ -5,39 +5,101 @@ 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.AuthenticationService
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.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 miscModule = module {
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<ConfigurationRepository> { Toml.decodeFromStream(configFile.inputStream()) }
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
},
)
}
System.getProperty("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 {
Database.connect(
TransactionManager.defaultDatabase = Database.connect(
url = System.getProperty("DB_URL"),
user = System.getProperty("DB_USER"),
password = System.getProperty("DB_PASSWORD"),
)
}
singleOf(::AnnouncementRepository)
singleOf(::GitHubBackendRepository)
single<BackendRepository> {
val backendServices = mapOf(
GitHubBackendRepository.SERVICE_NAME to { get<GitHubBackendRepository>() },
// Implement more backend services here.
)
val configuration = get<ConfigurationRepository>()
val backendFactory = backendServices[configuration.backendServiceName]!!
backendFactory()
AnnouncementRepository()
}
}
@ -51,6 +113,15 @@ fun Application.configureDependencies(
AuthenticationService(issuer, validityInMin, jwtSecret, authSHA256DigestString)
}
single {
val configuration = get<ConfigurationRepository>()
OldApiService(
get {
parameterArrayOf(configuration.oldApiEndpoint)
},
)
}
singleOf(::AnnouncementService)
singleOf(::SignatureService)
singleOf(::PatchesService)
@ -60,6 +131,7 @@ fun Application.configureDependencies(
install(Koin) {
modules(
miscModule,
repositoryModule,
serviceModule,
)

View File

@ -11,7 +11,7 @@ import org.koin.ktor.ext.get
import kotlin.time.Duration.Companion.minutes
fun Application.configureHTTP() {
val configuration = get<ConfigurationRepository>()
val configurationRepository = get<ConfigurationRepository>()
install(CORS) {
HttpMethod.DefaultMethods.minus(HttpMethod.Options).forEach(::allowMethod)
@ -22,7 +22,7 @@ fun Application.configureHTTP() {
allowCredentials = true
configuration.corsAllowedHosts.forEach { host ->
configurationRepository.corsAllowedHosts.forEach { host ->
allowHost(host = host, schemes = listOf("https"))
}
}

View File

@ -2,7 +2,6 @@ package app.revanced.api.configuration
import app.revanced.api.command.applicationVersion
import app.revanced.api.configuration.repository.ConfigurationRepository
import io.bkbn.kompendium.core.attribute.KompendiumAttributes
import io.bkbn.kompendium.core.plugin.NotarizedApplication
import io.bkbn.kompendium.json.schema.KotlinXSchemaConfigurator
import io.bkbn.kompendium.oas.OpenApiSpec
@ -13,22 +12,13 @@ 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 io.ktor.server.response.*
import io.ktor.server.routing.*
import org.koin.ktor.ext.get
import java.net.URI
import org.koin.ktor.ext.get as koinGet
internal fun Application.configureOpenAPI() {
val configuration = koinGet<ConfigurationRepository>()
val configurationRepository = get<ConfigurationRepository>()
install(NotarizedApplication()) {
openApiJson = {
route("/${configuration.apiVersion}/openapi.json") {
get {
call.respond(application.attributes[KompendiumAttributes.openApiSpec])
}
}
}
spec = OpenApiSpec(
info = Info(
title = "ReVanced API",
@ -51,7 +41,7 @@ internal fun Application.configureOpenAPI() {
),
).apply {
servers += Server(
url = URI(configuration.endpoint),
url = URI(configurationRepository.endpoint),
description = "ReVanced API server",
)
}

View File

@ -4,6 +4,7 @@ import app.revanced.api.configuration.repository.ConfigurationRepository
import app.revanced.api.configuration.routes.*
import app.revanced.api.configuration.routes.announcementsRoute
import app.revanced.api.configuration.routes.apiRoute
import app.revanced.api.configuration.routes.oldApiRoute
import app.revanced.api.configuration.routes.patchesRoute
import io.bkbn.kompendium.core.routes.redoc
import io.bkbn.kompendium.core.routes.swagger
@ -18,7 +19,7 @@ internal fun Application.configureRouting() = routing {
installCache(5.minutes)
route("/${configuration.apiVersion}") {
route("/v${configuration.apiVersion}") {
announcementsRoute()
patchesRoute()
managerRoute()
@ -52,7 +53,9 @@ internal fun Application.configureRouting() = routing {
extensions("json", "asc")
}
val specUrl = "/${configuration.apiVersion}/openapi.json"
swagger(pageTitle = "ReVanced API", path = "/", specUrl = specUrl)
redoc(pageTitle = "ReVanced API", path = "/redoc", specUrl = specUrl)
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

@ -1,11 +1,12 @@
package app.revanced.api.configuration.repository
import app.revanced.api.configuration.ApiAnnouncement
import app.revanced.api.configuration.ApiAnnouncementTag
import app.revanced.api.configuration.ApiResponseAnnouncement
import app.revanced.api.configuration.ApiResponseAnnouncementId
import app.revanced.api.configuration.schema.ApiAnnouncement
import app.revanced.api.configuration.schema.ApiAnnouncementTag
import app.revanced.api.configuration.schema.ApiResponseAnnouncement
import app.revanced.api.configuration.schema.ApiResponseAnnouncementId
import kotlinx.coroutines.Dispatchers
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
@ -14,11 +15,12 @@ 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 java.time.LocalDateTime
internal class AnnouncementRepository(private val database: Database) {
internal class AnnouncementRepository {
// This is better than doing a maxByOrNull { it.id } on every request.
private var latestAnnouncement: Announcement? = null
private val latestAnnouncementByTag = mutableMapOf<String, Announcement>()
private val latestAnnouncementByTag = mutableMapOf<Int, Announcement>()
init {
runBlocking {
@ -38,23 +40,22 @@ internal class AnnouncementRepository(private val database: Database) {
private fun initializeLatestAnnouncements() {
latestAnnouncement = Announcement.all().orderBy(Announcements.id to SortOrder.DESC).firstOrNull()
Tag.all().map { it.name }.forEach(::updateLatestAnnouncementForTag)
Tag.all().map { it.id.value }.forEach(::updateLatestAnnouncementForTag)
}
private fun updateLatestAnnouncement(new: Announcement) {
if (latestAnnouncement == null || latestAnnouncement!!.id.value <= new.id.value) {
latestAnnouncement = new
new.tags.forEach { tag -> latestAnnouncementByTag[tag.name] = new }
new.tags.forEach { tag -> latestAnnouncementByTag[tag.id.value] = new }
}
}
private fun updateLatestAnnouncementForTag(tag: String) {
val latestAnnouncementForTag = Tags.innerJoin(AnnouncementTags)
.select(AnnouncementTags.announcement)
.where { Tags.name eq tag }
.orderBy(AnnouncementTags.announcement to SortOrder.DESC)
.limit(1)
.firstNotNullOfOrNull { Announcement.findById(it[AnnouncementTags.announcement]) }
private fun updateLatestAnnouncementForTag(tag: Int) {
val latestAnnouncementForTag = AnnouncementTags.select(AnnouncementTags.announcement)
.where { AnnouncementTags.tag eq tag }
.map { it[AnnouncementTags.announcement] }
.mapNotNull { Announcement.findById(it) }
.maxByOrNull { it.id }
latestAnnouncementForTag?.let { latestAnnouncementByTag[tag] = it }
}
@ -63,29 +64,42 @@ internal class AnnouncementRepository(private val database: Database) {
latestAnnouncement.toApiResponseAnnouncement()
}
suspend fun latest(tags: Set<String>) = transaction {
suspend fun latest(tags: Set<Int>) = transaction {
tags.mapNotNull { tag -> latestAnnouncementByTag[tag] }.toApiAnnouncement()
}
fun latestId() = latestAnnouncement?.id?.value.toApiResponseAnnouncementId()
fun latestId(tags: Set<String>) = tags.map { tag -> latestAnnouncementByTag[tag]?.id?.value }.toApiResponseAnnouncementId()
fun latestId(tags: Set<Int>) =
tags.map { tag -> latestAnnouncementByTag[tag]?.id?.value }.toApiResponseAnnouncementId()
suspend fun paged(cursor: Int, count: Int, tags: Set<String>?) = transaction {
suspend fun paged(cursor: Int, count: Int, tags: Set<Int>?, archived: Boolean) = transaction {
Announcement.find {
fun idLessEq() = Announcements.id lessEq cursor
fun archivedAtIsNull() = Announcements.archivedAt.isNull()
fun archivedAtGreaterNow() = Announcements.archivedAt greater LocalDateTime.now().toKotlinLocalDateTime()
if (tags == null) {
if (archived) {
idLessEq()
} else {
fun hasTags() = Announcements.id inSubQuery (
AnnouncementTags.innerJoin(Tags)
.select(AnnouncementTags.announcement)
.withDistinct()
.where { Tags.name inList tags }
)
idLessEq() and (archivedAtIsNull() or archivedAtGreaterNow())
}
} else {
fun archivedAtGreaterOrNullOrTrue() = if (archived) {
Op.TRUE
} else {
archivedAtIsNull() or archivedAtGreaterNow()
}
idLessEq() and hasTags()
fun hasTags() = tags.mapNotNull { Tag.findById(it)?.id }.let { tags ->
Announcements.id inSubQuery Announcements.leftJoin(AnnouncementTags)
.select(AnnouncementTags.announcement)
.where { AnnouncementTags.tag inList tags }
.withDistinct()
}
idLessEq() and archivedAtGreaterOrNullOrTrue() and hasTags()
}
}.orderBy(Announcements.id to SortOrder.DESC).limit(count).toApiAnnouncement()
}
@ -99,16 +113,13 @@ internal class AnnouncementRepository(private val database: Database) {
author = new.author
title = new.title
content = new.content
createdAt = new.createdAt
archivedAt = new.archivedAt
level = new.level
if (new.tags != null) {
tags = SizedCollection(
new.tags.map { tag -> Tag.find { Tags.name eq tag }.firstOrNull() ?: Tag.new { name = tag } },
)
}
}.apply {
new.attachments?.map { attachmentUrl ->
new.attachments.map { attachmentUrl ->
Attachment.new {
url = attachmentUrl
announcement = this@apply
@ -122,11 +133,9 @@ internal class AnnouncementRepository(private val database: Database) {
it.author = new.author
it.title = new.title
it.content = new.content
it.createdAt = new.createdAt
it.archivedAt = new.archivedAt
it.level = new.level
if (new.tags != null) {
// Get the old tags, create new tags if they don't exist,
// and delete tags that are not in the new tags, after updating the announcement.
val oldTags = it.tags.toList()
@ -138,10 +147,8 @@ internal class AnnouncementRepository(private val database: Database) {
if (tag in updatedTags || !tag.announcements.empty()) return@forEach
tag.delete()
}
}
// Delete old attachments and create new attachments.
if (new.attachments != null) {
it.attachments.forEach { attachment -> attachment.delete() }
new.attachments.map { attachment ->
Attachment.new {
@ -149,7 +156,6 @@ internal class AnnouncementRepository(private val database: Database) {
announcement = it
}
}
}
}?.let(::updateLatestAnnouncement) ?: Unit
}
@ -159,7 +165,7 @@ internal class AnnouncementRepository(private val database: Database) {
// Delete the tag if no other announcements are referencing it.
// One count means that the announcement is the only one referencing the tag.
announcement.tags.filter { tag -> tag.announcements.count() == 1L }.forEach { tag ->
latestAnnouncementByTag -= tag.name
latestAnnouncementByTag -= tag.id.value
tag.delete()
}
@ -180,7 +186,8 @@ internal class AnnouncementRepository(private val database: Database) {
Tag.all().toList().toApiTag()
}
private suspend fun <T> transaction(statement: suspend Transaction.() -> T) = newSuspendedTransaction(Dispatchers.IO, database, statement = statement)
private suspend fun <T> transaction(statement: suspend Transaction.() -> T) =
newSuspendedTransaction(Dispatchers.IO, statement = statement)
private object Announcements : IntIdTable() {
val author = varchar("author", 32).nullable()
@ -243,7 +250,7 @@ internal class AnnouncementRepository(private val database: Database) {
title,
content,
attachments.map { it.url },
tags.map { it.name },
tags.map { it.id.value },
createdAt,
archivedAt,
level,
@ -252,7 +259,7 @@ internal class AnnouncementRepository(private val database: Database) {
private fun Iterable<Announcement>.toApiAnnouncement() = map { it.toApiResponseAnnouncement()!! }
private fun Iterable<Tag>.toApiTag() = map { ApiAnnouncementTag(it.name) }
private fun Iterable<Tag>.toApiTag() = map { ApiAnnouncementTag(it.id.value, it.name) }
private fun Int?.toApiResponseAnnouncementId() = this?.let { ApiResponseAnnouncementId(this) }

View File

@ -1,59 +1,16 @@
package app.revanced.api.configuration.repository
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 kotlinx.datetime.LocalDateTime
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
/**
* The backend of the API used to get data.
*
* @param defaultRequestUri The URI to use for requests.
* @param website The site of the backend users can visit.
* @param client The HTTP client to use for requests.
*/
abstract class BackendRepository internal constructor(
defaultRequestUri: String,
internal val website: String,
protected val client: HttpClient,
) {
protected val client: HttpClient = HttpClient(OkHttp) {
defaultRequest { url(defaultRequestUri) }
install(HttpCache)
install(Resources)
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
@Suppress("OPT_IN_USAGE")
namingStrategy = JsonNamingStrategy.SnakeCase
},
)
}
System.getProperty("BACKEND_API_TOKEN")?.let {
install(Auth) {
bearer {
loadTokens {
BearerTokens(
accessToken = it,
refreshToken = "", // Required dummy value
)
}
sendWithoutRequest { true }
}
}
}
}
/**
* A user.
*
@ -135,14 +92,12 @@ abstract class BackendRepository internal constructor(
* @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 prerelease Whether the release is a prerelease.
* @property releaseNote The release note of the release.
*/
class BackendRelease(
val tag: String,
val releaseNote: String,
val createdAt: LocalDateTime,
val prerelease: Boolean,
// Using a list instead of a set because set semantics are unnecessary here.
val assets: List<BackendAsset>,
) {
@ -182,13 +137,13 @@ abstract class BackendRepository internal constructor(
*
* @param owner The owner of the repository.
* @param repository The name of the repository.
* @param prerelease Whether to get a prerelease.
* @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,
prerelease: Boolean,
tag: String? = null,
): BackendOrganization.BackendRepository.BackendRelease
/**
@ -198,10 +153,7 @@ abstract class BackendRepository internal constructor(
* @param repository The name of the repository.
* @return The contributors.
*/
abstract suspend fun contributors(
owner: String,
repository: String,
): List<BackendOrganization.BackendRepository.BackendContributor>
abstract suspend fun contributors(owner: String, repository: String): List<BackendOrganization.BackendRepository.BackendContributor>
/**
* Get the members of an organization.

View File

@ -1,6 +1,6 @@
package app.revanced.api.configuration.repository
import app.revanced.api.configuration.APIAbout
import app.revanced.api.configuration.schema.APIAbout
import app.revanced.api.configuration.services.ManagerService
import app.revanced.api.configuration.services.PatchesService
import kotlinx.serialization.ExperimentalSerializationApi
@ -22,14 +22,15 @@ import kotlin.io.path.createDirectories
/**
* The repository storing the configuration for the API.
*
* @property organization The API backends organization name where the repositories are.
* @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 manager The source of the manager.
* @property contributorsRepositoryNames The friendly name of repos mapped to the repository names to get contributors from.
* @property backendServiceName The name of the backend service to use for the repositories, contributors, etc.
* @property contributorsRepositoryNames The names of the repositories to get contributors from.
* @property apiVersion The version to use for the API.
* @property corsAllowedHosts The hosts allowed to make requests to the API.
* @property endpoint The endpoint of the API.
* @property oldApiEndpoint The endpoint of the old API to proxy requests to.
* @property staticFilesPath The path to the static files to be served under the root path.
* @property versionedStaticFilesPath The path to the static files to be served under a versioned path.
* @property about The path to the json file deserialized to [APIAbout]
@ -39,16 +40,17 @@ import kotlin.io.path.createDirectories
internal class ConfigurationRepository(
val organization: String,
val patches: SignedAssetConfiguration,
val integrations: SignedAssetConfiguration,
val manager: AssetConfiguration,
@SerialName("contributors-repositories")
val contributorsRepositoryNames: Map<String, String>,
@SerialName("backend-service-name")
val backendServiceName: String,
val contributorsRepositoryNames: Set<String>,
@SerialName("api-version")
val apiVersion: String = "v1",
val apiVersion: Int = 1,
@SerialName("cors-allowed-hosts")
val corsAllowedHosts: Set<String>,
val endpoint: String,
@SerialName("old-api-endpoint")
val oldApiEndpoint: String,
@Serializable(with = PathSerializer::class)
@SerialName("static-files-path")
val staticFilesPath: Path,

View File

@ -8,26 +8,25 @@ import app.revanced.api.configuration.repository.GitHubOrganization.GitHubReposi
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.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.*
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
class GitHubBackendRepository : BackendRepository("https://api.github.com", "https://github.com") {
class GitHubBackendRepository(client: HttpClient) : BackendRepository(client) {
override suspend fun release(
owner: String,
repository: String,
prerelease: Boolean,
tag: String?,
): BackendRelease {
val release: GitHubRelease = if (prerelease) {
client.get(Releases(owner, repository)).body<List<GitHubRelease>>().first { it.prerelease }
val release: GitHubRelease = if (tag != null) {
client.get(Releases.Tag(owner, repository, tag)).body()
} else {
client.get(Releases.Latest(owner, repository)).body()
}
@ -36,7 +35,6 @@ class GitHubBackendRepository : BackendRepository("https://api.github.com", "htt
tag = release.tagName,
releaseNote = release.body,
createdAt = release.createdAt.toLocalDateTime(TimeZone.UTC),
prerelease = release.prerelease,
assets = release.assets.map {
BackendAsset(
name = it.name,
@ -69,8 +67,7 @@ class GitHubBackendRepository : BackendRepository("https://api.github.com", "htt
override suspend fun members(organization: String): List<BackendMember> {
// Get the list of members of the organization.
val publicMembers: List<GitHubOrganization.GitHubMember> =
client.get(Organization.PublicMembers(organization)).body()
val publicMembers: List<GitHubOrganization.GitHubMember> = client.get(Organization.PublicMembers(organization)).body()
return coroutineScope {
publicMembers.map { member ->
@ -116,10 +113,6 @@ class GitHubBackendRepository : BackendRepository("https://api.github.com", "htt
reset = Instant.fromEpochSeconds(rateLimit.rate.reset).toLocalDateTime(TimeZone.UTC),
)
}
companion object {
const val SERVICE_NAME = "GitHub"
}
}
interface IGitHubUser {
@ -164,7 +157,6 @@ class GitHubOrganization {
// Using a list instead of a set because set semantics are unnecessary here.
val assets: List<GitHubAsset>,
val createdAt: Instant,
val prerelease: Boolean,
val body: String,
) {
@Serializable
@ -202,8 +194,10 @@ class Organization {
@Resource("/repos/{owner}/{repo}/contributors")
class Contributors(val owner: String, val repo: String, @SerialName("per_page") val perPage: Int = 100)
@Resource("/repos/{owner}/{repo}/releases")
class Releases(val owner: String, val repo: String) {
class Releases {
@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)
}

View File

@ -1,17 +1,14 @@
package app.revanced.api.configuration.routes
import app.revanced.api.configuration.ApiAnnouncement
import app.revanced.api.configuration.ApiResponseAnnouncement
import app.revanced.api.configuration.ApiResponseAnnouncementId
import app.revanced.api.configuration.canRespondUnauthorized
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.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.core.metadata.*
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.payload.Parameter
import io.ktor.http.*
@ -36,8 +33,9 @@ internal fun Route.announcementsRoute() = route("announcements") {
val cursor = call.parameters["cursor"]?.toInt() ?: Int.MAX_VALUE
val count = call.parameters["count"]?.toInt() ?: 16
val tags = call.parameters.getAll("tag")
val archived = call.parameters["archived"]?.toBoolean() ?: true
call.respond(announcementService.paged(cursor, count, tags?.toSet()))
call.respond(announcementService.paged(cursor, count, tags?.map { it.toInt() }?.toSet(), archived))
}
}
@ -57,7 +55,7 @@ internal fun Route.announcementsRoute() = route("announcements") {
val tags = call.parameters.getAll("tag")
if (tags?.isNotEmpty() == true) {
call.respond(announcementService.latest(tags.toSet()))
call.respond(announcementService.latest(tags.map { it.toInt() }.toSet()))
} else {
call.respondOrNotFound(announcementService.latest())
}
@ -70,7 +68,7 @@ internal fun Route.announcementsRoute() = route("announcements") {
val tags = call.parameters.getAll("tag")
if (tags?.isNotEmpty() == true) {
call.respond(announcementService.latestId(tags.toSet()))
call.respond(announcementService.latestId(tags.map { it.toInt() }.toSet()))
} else {
call.respondOrNotFound(announcementService.latestId())
}
@ -148,8 +146,15 @@ private fun Route.installAnnouncementsRouteDocumentation() = installNotarizedRou
Parameter(
name = "tag",
`in` = Parameter.Location.query,
schema = TypeDefinition.STRING,
description = "The tags to filter the announcements by. Default is all tags",
schema = TypeDefinition.INT,
description = "The tag IDs to filter the announcements by. Default is all tags",
required = false,
),
Parameter(
name = "archived",
`in` = Parameter.Location.query,
schema = TypeDefinition.BOOLEAN,
description = "Whether to include archived announcements. Default is true",
required = false,
),
)
@ -188,8 +193,8 @@ private fun Route.installAnnouncementsLatestRouteDocumentation() = installNotari
Parameter(
name = "tag",
`in` = Parameter.Location.query,
schema = TypeDefinition.STRING,
description = "The tags to filter the latest announcements by",
schema = TypeDefinition.INT,
description = "The tag IDs to filter the latest announcements by",
required = false,
),
)
@ -223,8 +228,8 @@ private fun Route.installAnnouncementsLatestIdRouteDocumentation() = installNota
Parameter(
name = "tag",
`in` = Parameter.Location.query,
schema = TypeDefinition.STRING,
description = "The tags to filter the latest announcements by",
schema = TypeDefinition.INT,
description = "The tag IDs to filter the latest announcements by",
required = false,
),
)

View File

@ -6,6 +6,7 @@ import app.revanced.api.configuration.installNoCache
import app.revanced.api.configuration.installNotarizedRoute
import app.revanced.api.configuration.repository.ConfigurationRepository
import app.revanced.api.configuration.respondOrNotFound
import app.revanced.api.configuration.schema.*
import app.revanced.api.configuration.services.ApiService
import app.revanced.api.configuration.services.AuthenticationService
import io.bkbn.kompendium.core.metadata.*
@ -183,7 +184,7 @@ private fun Route.installTokenRouteDocumentation() = installNotarizedRoute {
"username=\"ReVanced\", " +
"realm=\"ReVanced\", " +
"nonce=\"abc123\", " +
"uri=\"/${configuration.apiVersion}/token\", " +
"uri=\"/v${configuration.apiVersion}/token\", " +
"algorithm=SHA-256, " +
"response=\"yxz456\"",
),

View File

@ -1,12 +1,11 @@
package app.revanced.api.configuration.routes
import app.revanced.api.configuration.ApiRelease
import app.revanced.api.configuration.ApiReleaseVersion
import app.revanced.api.configuration.installNotarizedRoute
import app.revanced.api.configuration.schema.ApiManagerAsset
import app.revanced.api.configuration.schema.ApiRelease
import app.revanced.api.configuration.schema.ApiReleaseVersion
import app.revanced.api.configuration.services.ManagerService
import io.bkbn.kompendium.core.metadata.GetInfo
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.plugins.ratelimit.*
@ -15,60 +14,57 @@ import io.ktor.server.routing.*
import org.koin.ktor.ext.get as koinGet
internal fun Route.managerRoute() = route("manager") {
configure()
// TODO: Remove this deprecated route eventually.
route("latest") {
configure(deprecated = true)
}
}
private fun Route.configure(deprecated: Boolean = false) {
val managerService = koinGet<ManagerService>()
installManagerRouteDocumentation()
installManagerRouteDocumentation(deprecated)
rateLimit(RateLimitName("weak")) {
get {
val prerelease = call.parameters["prerelease"]?.toBoolean() ?: false
call.respond(managerService.latestRelease(prerelease))
call.respond(managerService.latestRelease())
}
route("version") {
installManagerVersionRouteDocumentation()
installManagerVersionRouteDocumentation(deprecated)
get {
val prerelease = call.parameters["prerelease"]?.toBoolean() ?: false
call.respond(managerService.latestVersion(prerelease))
call.respond(managerService.latestVersion())
}
}
}
}
private val prereleaseParameter = Parameter(
name = "prerelease",
`in` = Parameter.Location.query,
schema = TypeDefinition.STRING,
description = "Whether to get the current manager prerelease",
required = false,
)
private fun Route.installManagerRouteDocumentation() = installNotarizedRoute {
private fun Route.installManagerRouteDocumentation(deprecated: Boolean) = installNotarizedRoute {
tags = setOf("Manager")
get = GetInfo.builder {
if (deprecated) isDeprecated()
description("Get the current manager release")
summary("Get current manager release")
parameters(prereleaseParameter)
response {
description("The latest manager release")
mediaTypes("application/json")
responseCode(HttpStatusCode.OK)
responseType<ApiRelease>()
responseType<ApiRelease<ApiManagerAsset>>()
}
}
}
private fun Route.installManagerVersionRouteDocumentation() = installNotarizedRoute {
private fun Route.installManagerVersionRouteDocumentation(deprecated: Boolean) = installNotarizedRoute {
tags = setOf("Manager")
get = GetInfo.builder {
if (deprecated) isDeprecated()
description("Get the current manager release version")
summary("Get current manager release version")
parameters(prereleaseParameter)
response {
description("The current manager release version")
mediaTypes("application/json")

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

@ -1,14 +1,13 @@
package app.revanced.api.configuration.routes
import app.revanced.api.configuration.ApiAssetPublicKey
import app.revanced.api.configuration.ApiRelease
import app.revanced.api.configuration.ApiReleaseVersion
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.ApiPatchesAsset
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.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.plugins.ratelimit.*
@ -18,36 +17,39 @@ import kotlin.time.Duration.Companion.days
import org.koin.ktor.ext.get as koinGet
internal fun Route.patchesRoute() = route("patches") {
configure()
// TODO: Remove this deprecated route eventually.
route("latest") {
configure(deprecated = true)
}
}
private fun Route.configure(deprecated: Boolean = false) {
val patchesService = koinGet<PatchesService>()
installPatchesRouteDocumentation()
installPatchesRouteDocumentation(deprecated)
rateLimit(RateLimitName("weak")) {
get {
val prerelease = call.parameters["prerelease"]?.toBoolean() ?: false
call.respond(patchesService.latestRelease(prerelease))
call.respond(patchesService.latestRelease())
}
route("version") {
installPatchesVersionRouteDocumentation()
installPatchesVersionRouteDocumentation(deprecated)
get {
val prerelease = call.parameters["prerelease"]?.toBoolean() ?: false
call.respond(patchesService.latestVersion(prerelease))
call.respond(patchesService.latestVersion())
}
}
}
rateLimit(RateLimitName("strong")) {
route("list") {
installPatchesListRouteDocumentation()
installPatchesListRouteDocumentation(deprecated)
get {
val prerelease = call.parameters["prerelease"]?.toBoolean() ?: false
call.respondBytes(ContentType.Application.Json) { patchesService.list(prerelease) }
call.respondBytes(ContentType.Application.Json) { patchesService.list() }
}
}
}
@ -56,46 +58,38 @@ internal fun Route.patchesRoute() = route("patches") {
route("keys") {
installCache(356.days)
installPatchesPublicKeyRouteDocumentation()
installPatchesPublicKeyRouteDocumentation(deprecated)
get {
call.respond(patchesService.publicKey())
call.respond(patchesService.publicKeys())
}
}
}
}
private val prereleaseParameter = Parameter(
name = "prerelease",
`in` = Parameter.Location.query,
schema = TypeDefinition.STRING,
description = "Whether to get the current patches prerelease",
required = false,
)
private fun Route.installPatchesRouteDocumentation() = installNotarizedRoute {
private fun Route.installPatchesRouteDocumentation(deprecated: Boolean) = installNotarizedRoute {
tags = setOf("Patches")
get = GetInfo.builder {
if (deprecated) isDeprecated()
description("Get the current patches release")
summary("Get current patches release")
parameters(prereleaseParameter)
response {
description("The current patches release")
mediaTypes("application/json")
responseCode(HttpStatusCode.OK)
responseType<ApiRelease>()
responseType<ApiRelease<ApiPatchesAsset>>()
}
}
}
private fun Route.installPatchesVersionRouteDocumentation() = installNotarizedRoute {
private fun Route.installPatchesVersionRouteDocumentation(deprecated: Boolean) = installNotarizedRoute {
tags = setOf("Patches")
get = GetInfo.builder {
if (deprecated) isDeprecated()
description("Get the current patches release version")
summary("Get current patches release version")
parameters(prereleaseParameter)
response {
description("The current patches release version")
mediaTypes("application/json")
@ -105,13 +99,13 @@ private fun Route.installPatchesVersionRouteDocumentation() = installNotarizedRo
}
}
private fun Route.installPatchesListRouteDocumentation() = installNotarizedRoute {
private fun Route.installPatchesListRouteDocumentation(deprecated: Boolean) = installNotarizedRoute {
tags = setOf("Patches")
get = GetInfo.builder {
if (deprecated) isDeprecated()
description("Get the list of patches from the current patches release")
summary("Get list of patches from current patches release")
parameters(prereleaseParameter)
response {
description("The list of patches")
mediaTypes("application/json")
@ -121,17 +115,18 @@ private fun Route.installPatchesListRouteDocumentation() = installNotarizedRoute
}
}
private fun Route.installPatchesPublicKeyRouteDocumentation() = installNotarizedRoute {
private fun Route.installPatchesPublicKeyRouteDocumentation(deprecated: Boolean) = installNotarizedRoute {
tags = setOf("Patches")
get = GetInfo.builder {
description("Get the public keys for verifying patches assets")
summary("Get patches public keys")
if (deprecated) isDeprecated()
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<ApiAssetPublicKey>()
responseType<ApiAssetPublicKeys>()
}
}
}

View File

@ -1,9 +1,6 @@
package app.revanced.api.configuration
package app.revanced.api.configuration.schema
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.Serializable
interface ApiUser {
@ -38,20 +35,38 @@ class ApiContributor(
@Serializable
class APIContributable(
val name: String,
val url: String,
// Using a list instead of a set because set semantics are unnecessary here.
val contributors: List<ApiContributor>,
)
@Serializable
class ApiRelease(
class ApiRelease<T>(
val version: String,
val createdAt: LocalDateTime,
val description: String,
val downloadUrl: String,
val signatureDownloadUrl: String? = null,
// Using a list instead of a set because set semantics are unnecessary here.
val assets: List<T>,
)
@Serializable
class ApiManagerAsset(
val downloadUrl: String,
)
@Serializable
class ApiPatchesAsset(
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,
@ -63,10 +78,9 @@ class ApiAnnouncement(
val title: String,
val content: String? = null,
// Using a list instead of a set because set semantics are unnecessary here.
val attachments: List<String>? = null,
val attachments: List<String> = emptyList(),
// Using a list instead of a set because set semantics are unnecessary here.
val tags: List<String>? = null,
val createdAt: LocalDateTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()),
val tags: List<String> = emptyList(),
val archivedAt: LocalDateTime? = null,
val level: Int = 0,
)
@ -78,9 +92,9 @@ class ApiResponseAnnouncement(
val title: String,
val content: String? = null,
// Using a list instead of a set because set semantics are unnecessary here.
val attachments: List<String>? = null,
val attachments: List<String> = emptyList(),
// Using a list instead of a set because set semantics are unnecessary here.
val tags: List<String>? = null,
val tags: List<Int> = emptyList(),
val createdAt: LocalDateTime,
val archivedAt: LocalDateTime? = null,
val level: Int = 0,
@ -98,6 +112,7 @@ class ApiAnnouncementArchivedAt(
@Serializable
class ApiAnnouncementTag(
val id: Int,
val name: String,
)
@ -109,8 +124,9 @@ class ApiRateLimit(
)
@Serializable
class ApiAssetPublicKey(
class ApiAssetPublicKeys(
val patchesPublicKey: String,
val integrationsPublicKey: String,
)
@Serializable
@ -123,7 +139,6 @@ class APIAbout(
// Using a list instead of a set because set semantics are unnecessary here.
val socials: List<Social>?,
val donations: Donations?,
val status: String,
) {
@Serializable
class Branding(

View File

@ -1,21 +1,21 @@
package app.revanced.api.configuration.services
import app.revanced.api.configuration.ApiAnnouncement
import app.revanced.api.configuration.repository.AnnouncementRepository
import app.revanced.api.configuration.schema.ApiAnnouncement
internal class AnnouncementService(
private val announcementRepository: AnnouncementRepository,
) {
suspend fun latest(tags: Set<String>) = announcementRepository.latest(tags)
suspend fun latest(tags: Set<Int>) = announcementRepository.latest(tags)
suspend fun latest() = announcementRepository.latest()
fun latestId(tags: Set<String>) = announcementRepository.latestId(tags)
fun latestId(tags: Set<Int>) = announcementRepository.latestId(tags)
fun latestId() = announcementRepository.latestId()
suspend fun paged(cursor: Int, limit: Int, tags: Set<String>?) =
announcementRepository.paged(cursor, limit, tags)
suspend fun paged(cursor: Int, limit: Int, tags: Set<Int>?, archived: Boolean) =
announcementRepository.paged(cursor, limit, tags, archived)
suspend fun get(id: Int) = announcementRepository.get(id)

View File

@ -1,9 +1,8 @@
package app.revanced.api.configuration.services
import app.revanced.api.configuration.*
import app.revanced.api.configuration.repository.BackendRepository
import app.revanced.api.configuration.repository.ConfigurationRepository
import io.ktor.http.*
import app.revanced.api.configuration.schema.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@ -17,15 +16,11 @@ internal class ApiService(
val about = configurationRepository.about
suspend fun contributors() = withContext(Dispatchers.IO) {
configurationRepository.contributorsRepositoryNames.map { (repository, name) ->
configurationRepository.contributorsRepositoryNames.map {
async {
APIContributable(
name,
URLBuilder().apply {
takeFrom(backendRepository.website)
path(configurationRepository.organization, repository)
}.buildString(),
backendRepository.contributors(configurationRepository.organization, repository).map {
it,
backendRepository.contributors(configurationRepository.organization, it).map {
ApiContributor(it.name, it.avatarUrl, it.url, it.contributions)
},
)

View File

@ -1,6 +1,6 @@
package app.revanced.api.configuration.services
import app.revanced.api.configuration.ApiToken
import app.revanced.api.configuration.schema.ApiToken
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.auth.*

View File

@ -1,35 +1,36 @@
package app.revanced.api.configuration.services
import app.revanced.api.configuration.ApiRelease
import app.revanced.api.configuration.ApiReleaseVersion
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.*
internal class ManagerService(
private val backendRepository: BackendRepository,
private val configurationRepository: ConfigurationRepository,
) {
suspend fun latestRelease(prerelease: Boolean): ApiRelease {
suspend fun latestRelease(): ApiRelease<ApiManagerAsset> {
val managerRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.manager.repository,
prerelease,
)
val managerAsset = ApiManagerAsset(
managerRelease.assets.first(configurationRepository.manager.assetRegex).downloadUrl,
)
return ApiRelease(
managerRelease.tag,
managerRelease.createdAt,
managerRelease.releaseNote,
managerRelease.assets.first(configurationRepository.manager.assetRegex).downloadUrl,
listOf(managerAsset),
)
}
suspend fun latestVersion(prerelease: Boolean): ApiReleaseVersion {
suspend fun latestVersion(): ApiReleaseVersion {
val managerRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.manager.repository,
prerelease,
)
return ApiReleaseVersion(managerRelease.tag)

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

@ -1,11 +1,9 @@
package app.revanced.api.configuration.services
import app.revanced.api.configuration.ApiAssetPublicKey
import app.revanced.api.configuration.ApiRelease
import app.revanced.api.configuration.ApiReleaseVersion
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.serializeTo
import app.revanced.patcher.patch.loadPatchesFromJar
import com.github.benmanes.caffeine.cache.Caffeine
@ -19,27 +17,47 @@ internal class PatchesService(
private val backendRepository: BackendRepository,
private val configurationRepository: ConfigurationRepository,
) {
suspend fun latestRelease(prerelease: Boolean): ApiRelease {
suspend fun latestRelease(): ApiRelease<ApiPatchesAsset> {
val patchesRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.patches.repository,
prerelease,
)
val integrationsRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.integrations.repository,
)
fun ConfigurationRepository.SignedAssetConfiguration.asset(
release: BackendRepository.BackendOrganization.BackendRepository.BackendRelease,
assetName: ApiAssetName,
) = ApiPatchesAsset(
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,
patchesRelease.assets.first(configurationRepository.patches.assetRegex).downloadUrl,
patchesRelease.assets.first(configurationRepository.patches.signatureAssetRegex).downloadUrl,
listOf(patchesAsset, integrationsAsset),
)
}
suspend fun latestVersion(prerelease: Boolean): ApiReleaseVersion {
suspend fun latestVersion(): ApiReleaseVersion {
val patchesRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.patches.repository,
prerelease,
)
return ApiReleaseVersion(patchesRelease.tag)
@ -50,11 +68,10 @@ internal class PatchesService(
.maximumSize(1)
.build<String, ByteArray>()
suspend fun list(prerelease: Boolean): ByteArray {
suspend fun list(): ByteArray {
val patchesRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.patches.repository,
prerelease,
)
return withContext(Dispatchers.IO) {
@ -94,5 +111,14 @@ internal class PatchesService(
}
}
fun publicKey() = ApiAssetPublicKey(configurationRepository.patches.publicKeyFile.readText())
fun publicKeys(): ApiAssetPublicKeys {
fun readPublicKey(
getSignedAssetConfiguration: ConfigurationRepository.() -> ConfigurationRepository.SignedAssetConfiguration,
) = configurationRepository.getSignedAssetConfiguration().publicKeyFile.readText()
return ApiAssetPublicKeys(
readPublicKey { patches },
readPublicKey { integrations },
)
}
}

View File

@ -12,7 +12,7 @@ import java.security.MessageDigest
internal class SignatureService {
private val signatureCache = Caffeine
.newBuilder()
.maximumSize(2) // 2 because currently only the latest release and prerelease patches are needed.
.maximumSize(2) // Assuming this is enough for patches and integrations.
.build<ByteArray, Boolean>() // Hash -> Verified.
fun verify(

View File

@ -1,7 +1,7 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} %-5level %msg%n</pattern>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="\${LOG_LEVEL:-INFO}">

View File

@ -1,14 +1,13 @@
package app.revanced.api.configuration.services
import app.revanced.api.configuration.ApiAnnouncement
import app.revanced.api.configuration.repository.AnnouncementRepository
import app.revanced.api.configuration.schema.ApiAnnouncement
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.toKotlinLocalDateTime
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@ -19,9 +18,10 @@ private object AnnouncementServiceTest {
@JvmStatic
@BeforeAll
fun setUp() {
val database = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false")
TransactionManager.defaultDatabase =
Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false")
announcementService = AnnouncementService(AnnouncementRepository(database))
announcementService = AnnouncementService(AnnouncementRepository())
}
@BeforeEach
@ -86,22 +86,27 @@ private object AnnouncementServiceTest {
announcementService.new(ApiAnnouncement(title = "2", tags = listOf("tag1", "tag3")))
announcementService.new(ApiAnnouncement(title = "3", tags = listOf("tag1", "tag4")))
assert(announcementService.latest(setOf("tag2")).first().title == "1")
assert(announcementService.latest(setOf("tag3")).last().title == "2")
val tag2 = announcementService.tags().find { it.name == "tag2" }!!.id
assert(announcementService.latest(setOf(tag2)).first().title == "1")
val announcement2and3 = announcementService.latest(setOf("tag1", "tag3"))
val tag3 = announcementService.tags().find { it.name == "tag3" }!!.id
assert(announcementService.latest(setOf(tag3)).last().title == "2")
val tag1and3 =
announcementService.tags().filter { it.name == "tag1" || it.name == "tag3" }.map { it.id }.toSet()
val announcement2and3 = announcementService.latest(tag1and3)
assert(announcement2and3.size == 2)
assert(announcement2and3.any { it.title == "2" })
assert(announcement2and3.any { it.title == "3" })
announcementService.delete(announcementService.latestId()!!.id)
assert(announcementService.latest(setOf("tag1", "tag3")).first().title == "2")
assert(announcementService.latest(tag1and3).first().title == "2")
announcementService.delete(announcementService.latestId()!!.id)
assert(announcementService.latest(setOf("tag1", "tag3")).first().title == "1")
assert(announcementService.latest(tag1and3).first().title == "1")
announcementService.delete(announcementService.latestId()!!.id)
assert(announcementService.latest(setOf("tag1", "tag3")).isEmpty())
assert(announcementService.latest(tag1and3).isEmpty())
assert(announcementService.tags().isEmpty())
}
@ -135,7 +140,7 @@ private object AnnouncementServiceTest {
val latestAnnouncement = announcementService.latest()!!
val latestId = latestAnnouncement.id
val attachments = latestAnnouncement.attachments!!
val attachments = latestAnnouncement.attachments
assertEquals(2, attachments.size)
assert(attachments.any { it == "attachment1" })
assert(attachments.any { it == "attachment2" })
@ -144,7 +149,7 @@ private object AnnouncementServiceTest {
latestId,
ApiAnnouncement(title = "title", attachments = listOf("attachment1", "attachment3")),
)
assert(announcementService.get(latestId)!!.attachments!!.any { it == "attachment3" })
assert(announcementService.get(latestId)!!.attachments.any { it == "attachment3" })
}
@Test
@ -153,11 +158,11 @@ private object AnnouncementServiceTest {
announcementService.new(ApiAnnouncement(title = "title$it"))
}
val announcements = announcementService.paged(Int.MAX_VALUE, 5, null)
val announcements = announcementService.paged(Int.MAX_VALUE, 5, null, true)
assertEquals(5, announcements.size, "Returns correct number of announcements")
assertEquals("title9", announcements.first().title, "Starts from the latest announcement")
val announcements2 = announcementService.paged(5, 5, null)
val announcements2 = announcementService.paged(5, 5, null, true)
assertEquals(5, announcements2.size, "Returns correct number of announcements when starting from the cursor")
assertEquals("title4", announcements2.first().title, "Starts from the cursor")
@ -180,7 +185,10 @@ private object AnnouncementServiceTest {
val tags = announcementService.tags()
assertEquals(5, tags.size, "Returns correct number of newly created tags")
val announcements3 = announcementService.paged(5, 5, setOf(tags[1].name))
val announcements3 = announcementService.paged(5, 5, setOf(tags[1].id), true)
assertEquals(4, announcements3.size, "Filters announcements by tag")
val announcements4 = announcementService.paged(Int.MAX_VALUE, 10, null, false)
assertEquals(8, announcements4.size, "Filters out archived announcements")
}
}