chore: Merge branch dev to main (#194)

This commit is contained in:
oSumAtrIX 2024-11-06 05:04:05 +01:00 committed by GitHub
commit 440fbbc6c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 799 additions and 770 deletions

View File

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

1
.gitignore vendored
View File

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

View File

@ -1,3 +1,49 @@
# [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)
### Features
* Improve announcements API ([#192](https://github.com/ReVanced/revanced-api/issues/192)) ([56a00dd](https://github.com/ReVanced/revanced-api/commit/56a00ddb85f302d441f0b222a9902ea2c1c18897))
# [1.3.0](https://github.com/ReVanced/revanced-api/compare/v1.2.0...v1.3.0) (2024-10-07) # [1.3.0](https://github.com/ReVanced/revanced-api/compare/v1.2.0...v1.3.0) (2024-10-07)

View File

@ -68,19 +68,19 @@ API server for ReVanced.
## ❓ About ## ❓ About
ReVanced API is a server that is used as the backend for ReVanced. 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. with updates and ReVanced Patches.
## 💪 Features ## 💪 Features
Some of the features ReVanced API include: Some of the features ReVanced API include:
- 📢 **Announcements**: Post and get announcements grouped by channels - 📢 **Announcements**: Post and get announcements
- **About**: Get more information such as a description, ways to donate to, - **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 - 🧩 **Patches**: Get the latest updates of ReVanced Patches, directly from ReVanced API
- 👥 **Contributors**: List all contributors involved in the project - 👥 **Contributors**: List all contributors involved in the project
- 🔄 **Backwards compatibility**: Proxy an old API for migration purposes and backwards compatibility
## 🚀 How to get started ## 🚀 How to get started
@ -90,7 +90,8 @@ 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. 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, 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 ### 🗄️ Docker Compose
@ -114,8 +115,6 @@ so before you can pull the image, you need to [authenticate to the Container reg
-v $(pwd)/configuration.toml:/app/configuration.toml \ -v $(pwd)/configuration.toml:/app/configuration.toml \
# Mount the patches public key # Mount the patches public key
-v $(pwd)/patches-public-key.asc:/app/patches-public-key.asc \ -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 # Mount the static folder
-v $(pwd)/static:/app/static \ -v $(pwd)/static:/app/static \
# Mount the about.json file # Mount the about.json file
@ -141,7 +140,7 @@ A Java Runtime Environment (JRE) must be installed.
1. [Download](https://github.com/ReVanced/revanced-api/releases/latest) ReVanced API to a folder 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 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 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 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 5. Run `java -jar revanced-api.jar start` to start the server
@ -159,7 +158,8 @@ A Java Development Kit (JDK) and Git must be installed.
### 📙 Contributing ### 📙 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 ### 🛠️ Building

View File

@ -48,6 +48,12 @@ kotlin {
} }
} }
tasks {
test {
useJUnitPlatform()
}
}
repositories { repositories {
mavenCentral() mavenCentral()
google() google()
@ -98,6 +104,8 @@ dependencies {
implementation(libs.caffeine) implementation(libs.caffeine)
implementation(libs.bouncy.castle.provider) implementation(libs.bouncy.castle.provider)
implementation(libs.bouncy.castle.pgp) implementation(libs.bouncy.castle.pgp)
testImplementation(kotlin("test"))
} }
// The maven-publish plugin is necessary to make signing work. // The maven-publish plugin is necessary to make signing work.

View File

@ -1,22 +1,29 @@
organization = "revanced" api-version = "v1"
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 = [ cors-allowed-hosts = [
"revanced.app", "revanced.app",
"*.revanced.app" "*.revanced.app"
] ]
endpoint = "https://api.revanced.app" endpoint = "https://api.revanced.app"
old-api-endpoint = "https://old-api.revanced.app"
static-files-path = "static/root" static-files-path = "static/root"
versioned-static-files-path = "static/versioned" versioned-static-files-path = "static/versioned"
backend-service-name = "GitHub"
about-json-file-path = "about.json" 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,7 +7,6 @@ services:
- /data/revanced-api/.env:/app/.env - /data/revanced-api/.env:/app/.env
- /data/revanced-api/configuration.toml:/app/configuration.toml - /data/revanced-api/configuration.toml:/app/configuration.toml
- /data/revanced-api/patches-public-key.asc:/app/patches-public-key.asc - /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/static:/app/static
- /data/revanced-api/about.json:/app/about.json - /data/revanced-api/about.json:/app/about.json
environment: environment:

View File

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

View File

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

View File

@ -1,124 +1,112 @@
package app.revanced.api.configuration.schema package app.revanced.api.configuration
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
interface APIUser { interface ApiUser {
val name: String val name: String
val avatarUrl: String val avatarUrl: String
val url: String val url: String
} }
@Serializable @Serializable
class APIMember( class ApiMember(
override val name: String, override val name: String,
override val avatarUrl: String, override val avatarUrl: String,
override val url: String, override val url: String,
val bio: String?, val bio: String?,
val gpgKey: APIGpgKey?, val gpgKey: ApiGpgKey?,
) : APIUser ) : ApiUser
@Serializable @Serializable
class APIGpgKey( class ApiGpgKey(
val id: String, val id: String,
val url: String, val url: String,
) )
@Serializable @Serializable
class APIContributor( class ApiContributor(
override val name: String, override val name: String,
override val avatarUrl: String, override val avatarUrl: String,
override val url: String, override val url: String,
val contributions: Int, val contributions: Int,
) : APIUser ) : ApiUser
@Serializable @Serializable
class APIContributable( class APIContributable(
val name: String, val name: String,
val url: String,
// Using a list instead of a set because set semantics are unnecessary here. // Using a list instead of a set because set semantics are unnecessary here.
val contributors: List<APIContributor>, val contributors: List<ApiContributor>,
) )
@Serializable @Serializable
class APIRelease<T>( class ApiRelease(
val version: String, val version: String,
val createdAt: LocalDateTime, val createdAt: LocalDateTime,
val description: String, val description: String,
// Using a list instead of a set because set semantics are unnecessary here.
val assets: List<T>,
)
@Serializable
class APIManagerAsset(
val downloadUrl: String, val downloadUrl: String,
val signatureDownloadUrl: String? = null,
) )
@Serializable @Serializable
class APIPatchesAsset( class ApiReleaseVersion(
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, val version: String,
) )
@Serializable @Serializable
class APIAnnouncement( class ApiAnnouncement(
val author: String? = null, val author: String? = null,
val title: String, val title: String,
val content: String? = null, val content: String? = null,
// Using a list instead of a set because set semantics are unnecessary here. // Using a list instead of a set because set semantics are unnecessary here.
val attachmentUrls: List<String> = emptyList(), val attachments: List<String> = emptyList(),
val channel: String? = null, // Using a list instead of a set because set semantics are unnecessary here.
val tags: List<String> = emptyList(),
val archivedAt: LocalDateTime? = null, val archivedAt: LocalDateTime? = null,
val level: Int = 0, val level: Int = 0,
) )
@Serializable @Serializable
class APIResponseAnnouncement( class ApiResponseAnnouncement(
val id: Int, val id: Int,
val author: String? = null, val author: String? = null,
val title: String, val title: String,
val content: String? = null, val content: String? = null,
// Using a list instead of a set because set semantics are unnecessary here. // Using a list instead of a set because set semantics are unnecessary here.
val attachmentUrls: List<String> = emptyList(), val attachments: List<String> = emptyList(),
val channel: String? = null, // Using a list instead of a set because set semantics are unnecessary here.
val tags: List<String> = emptyList(),
val createdAt: LocalDateTime, val createdAt: LocalDateTime,
val archivedAt: LocalDateTime? = null, val archivedAt: LocalDateTime? = null,
val level: Int = 0, val level: Int = 0,
) )
@Serializable @Serializable
class APIResponseAnnouncementId( class ApiResponseAnnouncementId(
val id: Int, val id: Int,
) )
@Serializable @Serializable
class APIAnnouncementArchivedAt( class ApiAnnouncementArchivedAt(
val archivedAt: LocalDateTime, val archivedAt: LocalDateTime,
) )
@Serializable @Serializable
class APIRateLimit( class ApiAnnouncementTag(
val name: String,
)
@Serializable
class ApiRateLimit(
val limit: Int, val limit: Int,
val remaining: Int, val remaining: Int,
val reset: LocalDateTime, val reset: LocalDateTime,
) )
@Serializable @Serializable
class APIAssetPublicKeys( class ApiAssetPublicKey(
val patchesPublicKey: String, val patchesPublicKey: String,
val integrationsPublicKey: String,
) )
@Serializable @Serializable
@ -174,4 +162,4 @@ class APIAbout(
} }
@Serializable @Serializable
class APIToken(val token: String) class ApiToken(val token: String)

View File

@ -5,101 +5,39 @@ import app.revanced.api.configuration.repository.BackendRepository
import app.revanced.api.configuration.repository.ConfigurationRepository import app.revanced.api.configuration.repository.ConfigurationRepository
import app.revanced.api.configuration.repository.GitHubBackendRepository import app.revanced.api.configuration.repository.GitHubBackendRepository
import app.revanced.api.configuration.services.* 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.Toml
import com.akuleshov7.ktoml.source.decodeFromStream 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 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.Database
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.core.parameter.parameterArrayOf
import org.koin.dsl.module import org.koin.dsl.module
import org.koin.ktor.plugin.Koin import org.koin.ktor.plugin.Koin
import java.io.File import java.io.File
@OptIn(ExperimentalSerializationApi::class)
fun Application.configureDependencies( fun Application.configureDependencies(
configFile: File, 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 { val repositoryModule = module {
single<BackendRepository> { single<ConfigurationRepository> { Toml.decodeFromStream(configFile.inputStream()) }
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 { single {
TransactionManager.defaultDatabase = Database.connect( Database.connect(
url = System.getProperty("DB_URL"), url = System.getProperty("DB_URL"),
user = System.getProperty("DB_USER"), user = System.getProperty("DB_USER"),
password = System.getProperty("DB_PASSWORD"), 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.
)
AnnouncementRepository() val configuration = get<ConfigurationRepository>()
val backendFactory = backendServices[configuration.backendServiceName]!!
backendFactory()
} }
} }
@ -113,15 +51,6 @@ fun Application.configureDependencies(
AuthenticationService(issuer, validityInMin, jwtSecret, authSHA256DigestString) AuthenticationService(issuer, validityInMin, jwtSecret, authSHA256DigestString)
} }
single {
val configuration = get<ConfigurationRepository>()
OldApiService(
get {
parameterArrayOf(configuration.oldApiEndpoint)
},
)
}
singleOf(::AnnouncementService) singleOf(::AnnouncementService)
singleOf(::SignatureService) singleOf(::SignatureService)
singleOf(::PatchesService) singleOf(::PatchesService)
@ -131,7 +60,6 @@ fun Application.configureDependencies(
install(Koin) { install(Koin) {
modules( modules(
miscModule,
repositoryModule, repositoryModule,
serviceModule, serviceModule,
) )

View File

@ -4,7 +4,6 @@ import app.revanced.api.configuration.repository.ConfigurationRepository
import app.revanced.api.configuration.routes.* import app.revanced.api.configuration.routes.*
import app.revanced.api.configuration.routes.announcementsRoute import app.revanced.api.configuration.routes.announcementsRoute
import app.revanced.api.configuration.routes.apiRoute import app.revanced.api.configuration.routes.apiRoute
import app.revanced.api.configuration.routes.oldApiRoute
import app.revanced.api.configuration.routes.patchesRoute import app.revanced.api.configuration.routes.patchesRoute
import io.bkbn.kompendium.core.routes.redoc import io.bkbn.kompendium.core.routes.redoc
import io.bkbn.kompendium.core.routes.swagger import io.bkbn.kompendium.core.routes.swagger
@ -19,7 +18,7 @@ internal fun Application.configureRouting() = routing {
installCache(5.minutes) installCache(5.minutes)
route("/v${configuration.apiVersion}") { route("/${configuration.apiVersion}") {
announcementsRoute() announcementsRoute()
patchesRoute() patchesRoute()
managerRoute() managerRoute()
@ -55,7 +54,4 @@ internal fun Application.configureRouting() = routing {
swagger(pageTitle = "ReVanced API", path = "/") swagger(pageTitle = "ReVanced API", path = "/")
redoc(pageTitle = "ReVanced API", path = "/redoc") redoc(pageTitle = "ReVanced API", path = "/redoc")
// TODO: Remove, once migration period from v2 API is over (In 1-2 years).
oldApiRoute()
} }

View File

@ -1,12 +1,11 @@
package app.revanced.api.configuration.repository package app.revanced.api.configuration.repository
import app.revanced.api.configuration.schema.APIAnnouncement import app.revanced.api.configuration.ApiAnnouncement
import app.revanced.api.configuration.schema.APIResponseAnnouncement import app.revanced.api.configuration.ApiAnnouncementTag
import app.revanced.api.configuration.schema.APIResponseAnnouncementId import app.revanced.api.configuration.ApiResponseAnnouncement
import app.revanced.api.configuration.ApiResponseAnnouncementId
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.datetime.*
import org.jetbrains.exposed.dao.IntEntity import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.EntityID
@ -15,136 +14,172 @@ import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.kotlin.datetime.CurrentDateTime import org.jetbrains.exposed.sql.kotlin.datetime.CurrentDateTime
import org.jetbrains.exposed.sql.kotlin.datetime.datetime import org.jetbrains.exposed.sql.kotlin.datetime.datetime
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.transactions.experimental.suspendedTransactionAsync
internal class AnnouncementRepository { internal class AnnouncementRepository(private val database: Database) {
// This is better than doing a maxByOrNull { it.id }. // This is better than doing a maxByOrNull { it.id } on every request.
private var latestAnnouncement: Announcement? = null private var latestAnnouncement: Announcement? = null
private val latestAnnouncementByChannel = mutableMapOf<String, Announcement>() private val latestAnnouncementByTag = mutableMapOf<String, Announcement>()
private fun updateLatestAnnouncement(new: Announcement) {
if (latestAnnouncement?.id?.value == new.id.value) {
latestAnnouncement = new
latestAnnouncementByChannel[new.channel ?: return] = new
}
}
init { init {
runBlocking { runBlocking {
transaction { transaction {
SchemaUtils.create(Announcements, Attachments) SchemaUtils.create(
Announcements,
Attachments,
Tags,
AnnouncementTags,
)
// Initialize the latest announcement. initializeLatestAnnouncements()
latestAnnouncement = Announcement.all().onEach {
latestAnnouncementByChannel[it.channel ?: return@onEach] = it
}.maxByOrNull { it.id } ?: return@transaction
} }
} }
} }
suspend fun all() = transaction { private fun initializeLatestAnnouncements() {
Announcement.all().map { it.toApi() } latestAnnouncement = Announcement.all().orderBy(Announcements.id to SortOrder.DESC).firstOrNull()
Tag.all().map { it.name }.forEach(::updateLatestAnnouncementForTag)
} }
suspend fun all(channel: String) = transaction { private fun updateLatestAnnouncement(new: Announcement) {
Announcement.find { Announcements.channel eq channel }.map { it.toApi() } if (latestAnnouncement == null || latestAnnouncement!!.id.value <= new.id.value) {
latestAnnouncement = new
new.tags.forEach { tag -> latestAnnouncementByTag[tag.name] = 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]) }
latestAnnouncementForTag?.let { latestAnnouncementByTag[tag] = it }
}
suspend fun latest() = transaction {
latestAnnouncement.toApiResponseAnnouncement()
}
suspend fun latest(tags: Set<String>) = 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()
suspend fun paged(cursor: Int, count: Int, tags: Set<String>?) = transaction {
Announcement.find {
fun idLessEq() = Announcements.id lessEq cursor
if (tags == null) {
idLessEq()
} else {
fun hasTags() = Announcements.id inSubQuery (
AnnouncementTags.innerJoin(Tags)
.select(AnnouncementTags.announcement)
.withDistinct()
.where { Tags.name inList tags }
)
idLessEq() and hasTags()
}
}.orderBy(Announcements.id to SortOrder.DESC).limit(count).toApiAnnouncement()
}
suspend fun get(id: Int) = transaction {
Announcement.findById(id).toApiResponseAnnouncement()
}
suspend fun new(new: ApiAnnouncement) = transaction {
Announcement.new {
author = new.author
title = new.title
content = new.content
archivedAt = new.archivedAt
level = new.level
tags = SizedCollection(
new.tags.map { tag -> Tag.find { Tags.name eq tag }.firstOrNull() ?: Tag.new { name = tag } },
)
}.apply {
new.attachments.map { attachmentUrl ->
Attachment.new {
url = attachmentUrl
announcement = this@apply
}
}
}.let(::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.archivedAt = new.archivedAt
it.level = new.level
// 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()
val updatedTags = new.tags.map { name ->
Tag.find { Tags.name eq name }.firstOrNull() ?: Tag.new { this.name = name }
}
it.tags = SizedCollection(updatedTags)
oldTags.forEach { tag ->
if (tag in updatedTags || !tag.announcements.empty()) return@forEach
tag.delete()
}
// Delete old attachments and create new attachments.
it.attachments.forEach { attachment -> attachment.delete() }
new.attachments.map { attachment ->
Attachment.new {
url = attachment
announcement = it
}
}
}?.let(::updateLatestAnnouncement) ?: Unit
} }
suspend fun delete(id: Int) = transaction { suspend fun delete(id: Int) = transaction {
val announcement = Announcement.findById(id) ?: return@transaction val announcement = Announcement.findById(id) ?: return@transaction
// 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
tag.delete()
}
announcement.delete() announcement.delete()
// In case the latest announcement was deleted, query the new latest announcement again. // If the deleted announcement is the latest announcement, set the new latest announcement.
if (latestAnnouncement?.id?.value == id) { if (latestAnnouncement?.id?.value == id) {
latestAnnouncement = Announcement.all().maxByOrNull { it.id } latestAnnouncement = Announcement.all().orderBy(Announcements.id to SortOrder.DESC).firstOrNull()
}
// If no latest announcement was found, remove it from the channel map. // The new announcement may be the latest for a specific tag. Set the new latest announcement for that tag.
if (latestAnnouncement == null) { latestAnnouncementByTag.keys.forEach { tag ->
latestAnnouncementByChannel.remove(announcement.channel) updateLatestAnnouncementForTag(tag)
} else {
latestAnnouncementByChannel[latestAnnouncement!!.channel ?: return@transaction] = latestAnnouncement!!
}
} }
} }
fun latest() = latestAnnouncement?.toApi() suspend fun tags() = transaction {
Tag.all().toList().toApiTag()
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) = private suspend fun <T> transaction(statement: suspend Transaction.() -> T) =
newSuspendedTransaction(Dispatchers.IO, statement = statement) newSuspendedTransaction(Dispatchers.IO, database, statement = statement)
private object Announcements : IntIdTable() { private object Announcements : IntIdTable() {
val author = varchar("author", 32).nullable() val author = varchar("author", 32).nullable()
val title = varchar("title", 64) val title = varchar("title", 64)
val content = text("content").nullable() val content = text("content").nullable()
val channel = varchar("channel", 16).nullable()
val createdAt = datetime("createdAt").defaultExpression(CurrentDateTime) val createdAt = datetime("createdAt").defaultExpression(CurrentDateTime)
val archivedAt = datetime("archivedAt").nullable() val archivedAt = datetime("archivedAt").nullable()
val level = integer("level") val level = integer("level")
@ -155,6 +190,19 @@ internal class AnnouncementRepository {
val announcement = reference("announcement", Announcements, onDelete = ReferenceOption.CASCADE) val announcement = reference("announcement", Announcements, onDelete = ReferenceOption.CASCADE)
} }
private object Tags : IntIdTable() {
val name = varchar("name", 16).uniqueIndex()
}
private object AnnouncementTags : Table() {
val tag = reference("tag", Tags, onDelete = ReferenceOption.CASCADE)
val announcement = reference("announcement", Announcements, onDelete = ReferenceOption.CASCADE)
init {
uniqueIndex(tag, announcement)
}
}
class Announcement(id: EntityID<Int>) : IntEntity(id) { class Announcement(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<Announcement>(Announcements) companion object : IntEntityClass<Announcement>(Announcements)
@ -162,7 +210,7 @@ internal class AnnouncementRepository {
var title by Announcements.title var title by Announcements.title
var content by Announcements.content var content by Announcements.content
val attachments by Attachment referrersOn Attachments.announcement val attachments by Attachment referrersOn Attachments.announcement
var channel by Announcements.channel var tags by Tag via AnnouncementTags
var createdAt by Announcements.createdAt var createdAt by Announcements.createdAt
var archivedAt by Announcements.archivedAt var archivedAt by Announcements.archivedAt
var level by Announcements.level var level by Announcements.level
@ -175,17 +223,32 @@ internal class AnnouncementRepository {
var announcement by Announcement referencedOn Attachments.announcement var announcement by Announcement referencedOn Attachments.announcement
} }
private fun Announcement.toApi() = APIResponseAnnouncement( class Tag(id: EntityID<Int>) : IntEntity(id) {
id.value, companion object : IntEntityClass<Tag>(Tags)
author,
title,
content,
attachments.map { it.url },
channel,
createdAt,
archivedAt,
level,
)
private fun Int.toApi() = APIResponseAnnouncementId(this) var name by Tags.name
var announcements by Announcement via AnnouncementTags
}
private fun Announcement?.toApiResponseAnnouncement() = this?.let {
ApiResponseAnnouncement(
id.value,
author,
title,
content,
attachments.map { it.url },
tags.map { it.name },
createdAt,
archivedAt,
level,
)
}
private fun Iterable<Announcement>.toApiAnnouncement() = map { it.toApiResponseAnnouncement()!! }
private fun Iterable<Tag>.toApiTag() = map { ApiAnnouncementTag(it.name) }
private fun Int?.toApiResponseAnnouncementId() = this?.let { ApiResponseAnnouncementId(this) }
private fun Iterable<Int?>.toApiResponseAnnouncementId() = map { it.toApiResponseAnnouncementId() }
} }

View File

@ -1,16 +1,59 @@
package app.revanced.api.configuration.repository package app.revanced.api.configuration.repository
import io.ktor.client.* 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.datetime.LocalDateTime
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
/** /**
* The backend of the API used to get data. * The backend of the API used to get data.
* *
* @param client The HTTP client to use for requests. * @param defaultRequestUri The URI to use for requests.
* @param website The site of the backend users can visit.
*/ */
abstract class BackendRepository internal constructor( abstract class BackendRepository internal constructor(
protected val client: HttpClient, defaultRequestUri: String,
internal val website: String,
) { ) {
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. * A user.
* *
@ -153,7 +196,10 @@ abstract class BackendRepository internal constructor(
* @param repository The name of the repository. * @param repository The name of the repository.
* @return The contributors. * @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. * Get the members of an organization.

View File

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

View File

@ -8,18 +8,19 @@ import app.revanced.api.configuration.repository.GitHubOrganization.GitHubReposi
import app.revanced.api.configuration.repository.GitHubOrganization.GitHubRepository.GitHubRelease 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.Contributors
import app.revanced.api.configuration.repository.Organization.Repository.Releases import app.revanced.api.configuration.repository.Organization.Repository.Releases
import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.plugins.resources.* import io.ktor.client.plugins.resources.*
import io.ktor.resources.* import io.ktor.resources.*
import kotlinx.coroutines.* import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
class GitHubBackendRepository(client: HttpClient) : BackendRepository(client) { class GitHubBackendRepository : BackendRepository("https://api.github.com", "https://github.com") {
override suspend fun release( override suspend fun release(
owner: String, owner: String,
repository: String, repository: String,
@ -67,7 +68,8 @@ class GitHubBackendRepository(client: HttpClient) : BackendRepository(client) {
override suspend fun members(organization: String): List<BackendMember> { override suspend fun members(organization: String): List<BackendMember> {
// Get the list of members of the organization. // 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 { return coroutineScope {
publicMembers.map { member -> publicMembers.map { member ->
@ -113,6 +115,10 @@ class GitHubBackendRepository(client: HttpClient) : BackendRepository(client) {
reset = Instant.fromEpochSeconds(rateLimit.rate.reset).toLocalDateTime(TimeZone.UTC), reset = Instant.fromEpochSeconds(rateLimit.rate.reset).toLocalDateTime(TimeZone.UTC),
) )
} }
companion object {
const val SERVICE_NAME = "GitHub"
}
} }
interface IGitHubUser { interface IGitHubUser {

View File

@ -1,22 +1,23 @@
package app.revanced.api.configuration.routes 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.canRespondUnauthorized
import app.revanced.api.configuration.installCache import app.revanced.api.configuration.installCache
import app.revanced.api.configuration.installNotarizedRoute import app.revanced.api.configuration.installNotarizedRoute
import app.revanced.api.configuration.respondOrNotFound 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 app.revanced.api.configuration.services.AnnouncementService
import io.bkbn.kompendium.core.metadata.* 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.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.payload.Parameter import io.bkbn.kompendium.oas.payload.Parameter
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.auth.* import io.ktor.server.auth.*
import io.ktor.server.plugins.ratelimit.* import io.ktor.server.plugins.ratelimit.*
import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.util.* import io.ktor.server.util.*
@ -32,76 +33,62 @@ internal fun Route.announcementsRoute() = route("announcements") {
rateLimit(RateLimitName("strong")) { rateLimit(RateLimitName("strong")) {
get { get {
call.respond(announcementService.all()) val cursor = call.parameters["cursor"]?.toInt() ?: Int.MAX_VALUE
val count = call.parameters["count"]?.toInt() ?: 16
val tags = call.parameters.getAll("tag")
call.respond(announcementService.paged(cursor, count, tags?.toSet()))
} }
} }
rateLimit(RateLimitName("strong")) { rateLimit(RateLimitName("weak")) {
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") { authenticate("jwt") {
installAnnouncementRouteDocumentation() post<ApiAnnouncement> { announcement ->
post<APIAnnouncement> { announcement ->
announcementService.new(announcement) announcementService.new(announcement)
call.respond(HttpStatusCode.OK) call.respond(HttpStatusCode.OK)
} }
}
route("{id}") { route("latest") {
installAnnouncementIdRouteDocumentation() installAnnouncementsLatestRouteDocumentation()
patch<APIAnnouncement> { announcement -> get {
val tags = call.parameters.getAll("tag")
if (tags?.isNotEmpty() == true) {
call.respond(announcementService.latest(tags.toSet()))
} else {
call.respondOrNotFound(announcementService.latest())
}
}
route("id") {
installAnnouncementsLatestIdRouteDocumentation()
get {
val tags = call.parameters.getAll("tag")
if (tags?.isNotEmpty() == true) {
call.respond(announcementService.latestId(tags.toSet()))
} else {
call.respondOrNotFound(announcementService.latestId())
}
}
}
}
route("{id}") {
installAnnouncementsIdRouteDocumentation()
get {
val id: Int by call.parameters
call.respondOrNotFound(announcementService.get(id))
}
authenticate("jwt") {
patch<ApiAnnouncement> { announcement ->
val id: Int by call.parameters val id: Int by call.parameters
announcementService.update(id, announcement) announcementService.update(id, announcement)
@ -116,31 +103,14 @@ internal fun Route.announcementsRoute() = route("announcements") {
call.respond(HttpStatusCode.OK) call.respond(HttpStatusCode.OK)
} }
}
}
route("archive") { route("tags") {
installAnnouncementArchiveRouteDocumentation() installAnnouncementsTagsRouteDocumentation()
post { get {
val id: Int by call.parameters call.respond(announcementService.tags())
val archivedAt = call.receiveNullable<APIAnnouncementArchivedAt>()?.archivedAt
announcementService.archive(id, archivedAt)
call.respond(HttpStatusCode.OK)
}
}
route("unarchive") {
installAnnouncementUnarchiveRouteDocumentation()
post {
val id: Int by call.parameters
announcementService.unarchive(id)
call.respond(HttpStatusCode.OK)
}
}
} }
} }
} }
@ -154,16 +124,49 @@ private val authHeaderParameter = Parameter(
examples = mapOf("Bearer authentication" to Parameter.Example("Bearer abc123")), examples = mapOf("Bearer authentication" to Parameter.Example("Bearer abc123")),
) )
private fun Route.installAnnouncementRouteDocumentation() = installNotarizedRoute { private fun Route.installAnnouncementsRouteDocumentation() = installNotarizedRoute {
tags = setOf("Announcements") tags = setOf("Announcements")
parameters = listOf(authHeaderParameter) get = GetInfo.builder {
description("Get a page of announcements")
summary("Get announcements")
parameters(
Parameter(
name = "cursor",
`in` = Parameter.Location.query,
schema = TypeDefinition.INT,
description = "The offset of the announcements. Default is Int.MAX_VALUE (Newest first)",
required = false,
),
Parameter(
name = "count",
`in` = Parameter.Location.query,
schema = TypeDefinition.INT,
description = "The count of the announcements. Default is 16",
required = false,
),
Parameter(
name = "tag",
`in` = Parameter.Location.query,
schema = TypeDefinition.STRING,
description = "The tags to filter the announcements by. Default is all tags",
required = false,
),
)
response {
responseCode(HttpStatusCode.OK)
mediaTypes("application/json")
description("The announcements")
responseType<Set<ApiResponseAnnouncement>>()
}
}
post = PostInfo.builder { post = PostInfo.builder {
description("Create a new announcement") description("Create a new announcement")
summary("Create announcement") summary("Create announcement")
parameters(authHeaderParameter)
request { request {
requestType<APIAnnouncement>() requestType<ApiAnnouncement>()
description("The new announcement") description("The new announcement")
} }
response { response {
@ -175,17 +178,32 @@ private fun Route.installAnnouncementRouteDocumentation() = installNotarizedRout
} }
} }
private fun Route.installLatestAnnouncementRouteDocumentation() = installNotarizedRoute { private fun Route.installAnnouncementsLatestRouteDocumentation() = installNotarizedRoute {
tags = setOf("Announcements") tags = setOf("Announcements")
get = GetInfo.builder { get = GetInfo.builder {
description("Get the latest announcement") description("Get the latest announcement")
summary("Get latest announcement") summary("Get latest announcement")
parameters(
Parameter(
name = "tag",
`in` = Parameter.Location.query,
schema = TypeDefinition.STRING,
description = "The tags to filter the latest announcements by",
required = false,
),
)
response { response {
responseCode(HttpStatusCode.OK) responseCode(HttpStatusCode.OK)
mediaTypes("application/json") mediaTypes("application/json")
description("The latest announcement") description("The latest announcement")
responseType<APIResponseAnnouncement>() responseType<ApiResponseAnnouncement>()
}
canRespond {
responseCode(HttpStatusCode.OK)
mediaTypes("application/json")
description("The latest announcements")
responseType<Set<ApiResponseAnnouncement>>()
} }
canRespond { canRespond {
responseCode(HttpStatusCode.NotFound) responseCode(HttpStatusCode.NotFound)
@ -195,17 +213,32 @@ private fun Route.installLatestAnnouncementRouteDocumentation() = installNotariz
} }
} }
private fun Route.installLatestAnnouncementIdRouteDocumentation() = installNotarizedRoute { private fun Route.installAnnouncementsLatestIdRouteDocumentation() = installNotarizedRoute {
tags = setOf("Announcements") tags = setOf("Announcements")
get = GetInfo.builder { get = GetInfo.builder {
description("Get the id of the latest announcement") description("Get the ID of the latest announcement")
summary("Get id of latest announcement") summary("Get ID of latest announcement")
parameters(
Parameter(
name = "tag",
`in` = Parameter.Location.query,
schema = TypeDefinition.STRING,
description = "The tags to filter the latest announcements by",
required = false,
),
)
response { response {
responseCode(HttpStatusCode.OK) responseCode(HttpStatusCode.OK)
mediaTypes("application/json") mediaTypes("application/json")
description("The id of the latest announcement") description("The ID of the latest announcement")
responseType<APIResponseAnnouncementId>() responseType<ApiResponseAnnouncementId>()
}
canRespond {
responseCode(HttpStatusCode.OK)
mediaTypes("application/json")
description("The IDs of the latest announcements")
responseType<Set<ApiResponseAnnouncement>>()
} }
canRespond { canRespond {
responseCode(HttpStatusCode.NotFound) responseCode(HttpStatusCode.NotFound)
@ -215,109 +248,40 @@ private fun Route.installLatestAnnouncementIdRouteDocumentation() = installNotar
} }
} }
private fun Route.installChannelAnnouncementsRouteDocumentation() = installNotarizedRoute { private fun Route.installAnnouncementsIdRouteDocumentation() = installNotarizedRoute {
tags = setOf("Announcements") tags = setOf("Announcements")
parameters = listOf( parameters = listOf(
Parameter( Parameter(
name = "channel", name = "id",
`in` = Parameter.Location.path, `in` = Parameter.Location.path,
schema = TypeDefinition.STRING, schema = TypeDefinition.INT,
description = "The channel to get the announcements from", description = "The ID of the announcement to update",
required = true, required = true,
), ),
authHeaderParameter,
) )
get = GetInfo.builder { get = GetInfo.builder {
description("Get the announcements from a channel") description("Get an announcement")
summary("Get announcements from channel") summary("Get announcement")
response { response {
description("The announcement")
responseCode(HttpStatusCode.OK) responseCode(HttpStatusCode.OK)
mediaTypes("application/json") responseType<ApiResponseAnnouncement>()
description("The announcements in the channel")
responseType<Set<APIResponseAnnouncement>>()
} }
} canRespond {
} responseCode(HttpStatusCode.NotFound)
description("The announcement does not exist")
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,
),
authHeaderParameter,
)
post = PostInfo.builder {
description("Archive an announcement")
summary("Archive announcement")
response {
description("The announcement is archived")
responseCode(HttpStatusCode.OK)
responseType<Unit>() responseType<Unit>()
} }
canRespondUnauthorized()
} }
}
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,
),
authHeaderParameter,
)
post = PostInfo.builder {
description("Unarchive an announcement")
summary("Unarchive announcement")
response {
description("The announcement is unarchived")
responseCode(HttpStatusCode.OK)
responseType<Unit>()
}
canRespondUnauthorized()
}
}
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,
),
authHeaderParameter,
)
patch = PatchInfo.builder { patch = PatchInfo.builder {
description("Update an announcement") description("Update an announcement")
summary("Update announcement") summary("Update announcement")
request { request {
requestType<APIAnnouncement>() requestType<ApiAnnouncement>()
description("The new announcement") description("The new announcement")
} }
response { response {
@ -340,77 +304,17 @@ private fun Route.installAnnouncementIdRouteDocumentation() = installNotarizedRo
} }
} }
private fun Route.installAnnouncementsRouteDocumentation() = installNotarizedRoute { private fun Route.installAnnouncementsTagsRouteDocumentation() = installNotarizedRoute {
tags = setOf("Announcements") tags = setOf("Announcements")
get = GetInfo.builder { get = GetInfo.builder {
description("Get the announcements") description("Get all announcement tags")
summary("Get announcements") summary("Get announcement tags")
response { response {
responseCode(HttpStatusCode.OK) responseCode(HttpStatusCode.OK)
mediaTypes("application/json") mediaTypes("application/json")
description("The announcements") description("The announcement tags")
responseType<Set<APIResponseAnnouncement>>() responseType<Set<String>>()
}
}
}
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

@ -6,7 +6,6 @@ import app.revanced.api.configuration.installNoCache
import app.revanced.api.configuration.installNotarizedRoute import app.revanced.api.configuration.installNotarizedRoute
import app.revanced.api.configuration.repository.ConfigurationRepository import app.revanced.api.configuration.repository.ConfigurationRepository
import app.revanced.api.configuration.respondOrNotFound 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.ApiService
import app.revanced.api.configuration.services.AuthenticationService import app.revanced.api.configuration.services.AuthenticationService
import io.bkbn.kompendium.core.metadata.* import io.bkbn.kompendium.core.metadata.*
@ -115,7 +114,7 @@ private fun Route.installRateLimitRouteDocumentation() = installNotarizedRoute {
description("The rate limit of the backend") description("The rate limit of the backend")
mediaTypes("application/json") mediaTypes("application/json")
responseCode(HttpStatusCode.OK) responseCode(HttpStatusCode.OK)
responseType<APIRateLimit>() responseType<ApiRateLimit>()
} }
} }
} }
@ -144,7 +143,7 @@ private fun Route.installTeamRouteDocumentation() = installNotarizedRoute {
description("The list of team members") description("The list of team members")
mediaTypes("application/json") mediaTypes("application/json")
responseCode(HttpStatusCode.OK) responseCode(HttpStatusCode.OK)
responseType<Set<APIMember>>() responseType<Set<ApiMember>>()
} }
} }
} }
@ -184,7 +183,7 @@ private fun Route.installTokenRouteDocumentation() = installNotarizedRoute {
"username=\"ReVanced\", " + "username=\"ReVanced\", " +
"realm=\"ReVanced\", " + "realm=\"ReVanced\", " +
"nonce=\"abc123\", " + "nonce=\"abc123\", " +
"uri=\"/v${configuration.apiVersion}/token\", " + "uri=\"/${configuration.apiVersion}/token\", " +
"algorithm=SHA-256, " + "algorithm=SHA-256, " +
"response=\"yxz456\"", "response=\"yxz456\"",
), ),
@ -195,7 +194,7 @@ private fun Route.installTokenRouteDocumentation() = installNotarizedRoute {
description("The authorization token") description("The authorization token")
mediaTypes("application/json") mediaTypes("application/json")
responseCode(HttpStatusCode.OK) responseCode(HttpStatusCode.OK)
responseType<APIToken>() responseType<ApiToken>()
} }
canRespondUnauthorized() canRespondUnauthorized()
} }

View File

@ -1,9 +1,8 @@
package app.revanced.api.configuration.routes 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.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 app.revanced.api.configuration.services.ManagerService
import io.bkbn.kompendium.core.metadata.GetInfo import io.bkbn.kompendium.core.metadata.GetInfo
import io.ktor.http.* import io.ktor.http.*
@ -14,18 +13,9 @@ import io.ktor.server.routing.*
import org.koin.ktor.ext.get as koinGet import org.koin.ktor.ext.get as koinGet
internal fun Route.managerRoute() = route("manager") { 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>() val managerService = koinGet<ManagerService>()
installManagerRouteDocumentation(deprecated) installManagerRouteDocumentation()
rateLimit(RateLimitName("weak")) { rateLimit(RateLimitName("weak")) {
get { get {
@ -33,7 +23,7 @@ private fun Route.configure(deprecated: Boolean = false) {
} }
route("version") { route("version") {
installManagerVersionRouteDocumentation(deprecated) installManagerVersionRouteDocumentation()
get { get {
call.respond(managerService.latestVersion()) call.respond(managerService.latestVersion())
@ -42,34 +32,32 @@ private fun Route.configure(deprecated: Boolean = false) {
} }
} }
private fun Route.installManagerRouteDocumentation(deprecated: Boolean) = installNotarizedRoute { private fun Route.installManagerRouteDocumentation() = installNotarizedRoute {
tags = setOf("Manager") tags = setOf("Manager")
get = GetInfo.builder { get = GetInfo.builder {
if (deprecated) isDeprecated()
description("Get the current manager release") description("Get the current manager release")
summary("Get current manager release") summary("Get current manager release")
response { response {
description("The latest manager release") description("The latest manager release")
mediaTypes("application/json") mediaTypes("application/json")
responseCode(HttpStatusCode.OK) responseCode(HttpStatusCode.OK)
responseType<APIRelease<APIManagerAsset>>() responseType<ApiRelease>()
} }
} }
} }
private fun Route.installManagerVersionRouteDocumentation(deprecated: Boolean) = installNotarizedRoute { private fun Route.installManagerVersionRouteDocumentation() = installNotarizedRoute {
tags = setOf("Manager") tags = setOf("Manager")
get = GetInfo.builder { get = GetInfo.builder {
if (deprecated) isDeprecated()
description("Get the current manager release version") description("Get the current manager release version")
summary("Get current manager release version") summary("Get current manager release version")
response { response {
description("The current manager release version") description("The current manager release version")
mediaTypes("application/json") mediaTypes("application/json")
responseCode(HttpStatusCode.OK) responseCode(HttpStatusCode.OK)
responseType<APIReleaseVersion>() responseType<ApiReleaseVersion>()
} }
} }
} }

View File

@ -1,19 +0,0 @@
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,11 +1,10 @@
package app.revanced.api.configuration.routes 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.installCache
import app.revanced.api.configuration.installNotarizedRoute 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 app.revanced.api.configuration.services.PatchesService
import io.bkbn.kompendium.core.metadata.GetInfo import io.bkbn.kompendium.core.metadata.GetInfo
import io.ktor.http.* import io.ktor.http.*
@ -17,18 +16,9 @@ import kotlin.time.Duration.Companion.days
import org.koin.ktor.ext.get as koinGet import org.koin.ktor.ext.get as koinGet
internal fun Route.patchesRoute() = route("patches") { 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>() val patchesService = koinGet<PatchesService>()
installPatchesRouteDocumentation(deprecated) installPatchesRouteDocumentation()
rateLimit(RateLimitName("weak")) { rateLimit(RateLimitName("weak")) {
get { get {
@ -36,7 +26,7 @@ private fun Route.configure(deprecated: Boolean = false) {
} }
route("version") { route("version") {
installPatchesVersionRouteDocumentation(deprecated) installPatchesVersionRouteDocumentation()
get { get {
call.respond(patchesService.latestVersion()) call.respond(patchesService.latestVersion())
@ -46,7 +36,7 @@ private fun Route.configure(deprecated: Boolean = false) {
rateLimit(RateLimitName("strong")) { rateLimit(RateLimitName("strong")) {
route("list") { route("list") {
installPatchesListRouteDocumentation(deprecated) installPatchesListRouteDocumentation()
get { get {
call.respondBytes(ContentType.Application.Json) { patchesService.list() } call.respondBytes(ContentType.Application.Json) { patchesService.list() }
@ -58,52 +48,49 @@ private fun Route.configure(deprecated: Boolean = false) {
route("keys") { route("keys") {
installCache(356.days) installCache(356.days)
installPatchesPublicKeyRouteDocumentation(deprecated) installPatchesPublicKeyRouteDocumentation()
get { get {
call.respond(patchesService.publicKeys()) call.respond(patchesService.publicKey())
} }
} }
} }
} }
private fun Route.installPatchesRouteDocumentation(deprecated: Boolean) = installNotarizedRoute { private fun Route.installPatchesRouteDocumentation() = installNotarizedRoute {
tags = setOf("Patches") tags = setOf("Patches")
get = GetInfo.builder { get = GetInfo.builder {
if (deprecated) isDeprecated()
description("Get the current patches release") description("Get the current patches release")
summary("Get current patches release") summary("Get current patches release")
response { response {
description("The current patches release") description("The current patches release")
mediaTypes("application/json") mediaTypes("application/json")
responseCode(HttpStatusCode.OK) responseCode(HttpStatusCode.OK)
responseType<APIRelease<APIPatchesAsset>>() responseType<ApiRelease>()
} }
} }
} }
private fun Route.installPatchesVersionRouteDocumentation(deprecated: Boolean) = installNotarizedRoute { private fun Route.installPatchesVersionRouteDocumentation() = installNotarizedRoute {
tags = setOf("Patches") tags = setOf("Patches")
get = GetInfo.builder { get = GetInfo.builder {
if (deprecated) isDeprecated()
description("Get the current patches release version") description("Get the current patches release version")
summary("Get current patches release version") summary("Get current patches release version")
response { response {
description("The current patches release version") description("The current patches release version")
mediaTypes("application/json") mediaTypes("application/json")
responseCode(HttpStatusCode.OK) responseCode(HttpStatusCode.OK)
responseType<APIReleaseVersion>() responseType<ApiReleaseVersion>()
} }
} }
} }
private fun Route.installPatchesListRouteDocumentation(deprecated: Boolean) = installNotarizedRoute { private fun Route.installPatchesListRouteDocumentation() = installNotarizedRoute {
tags = setOf("Patches") tags = setOf("Patches")
get = GetInfo.builder { get = GetInfo.builder {
if (deprecated) isDeprecated()
description("Get the list of patches from the current patches release") description("Get the list of patches from the current patches release")
summary("Get list of patches from current patches release") summary("Get list of patches from current patches release")
response { response {
@ -115,18 +102,17 @@ private fun Route.installPatchesListRouteDocumentation(deprecated: Boolean) = in
} }
} }
private fun Route.installPatchesPublicKeyRouteDocumentation(deprecated: Boolean) = installNotarizedRoute { private fun Route.installPatchesPublicKeyRouteDocumentation() = installNotarizedRoute {
tags = setOf("Patches") tags = setOf("Patches")
get = GetInfo.builder { get = GetInfo.builder {
if (deprecated) isDeprecated() description("Get the public keys for verifying patches assets")
description("Get the public keys for verifying patches and integrations assets") summary("Get patches public keys")
summary("Get patches and integrations public keys")
response { response {
description("The public keys") description("The public keys")
mediaTypes("application/json") mediaTypes("application/json")
responseCode(HttpStatusCode.OK) responseCode(HttpStatusCode.OK)
responseType<APIAssetPublicKeys>() responseType<ApiAssetPublicKey>()
} }
} }
} }

View File

@ -1,35 +1,29 @@
package app.revanced.api.configuration.services package app.revanced.api.configuration.services
import app.revanced.api.configuration.ApiAnnouncement
import app.revanced.api.configuration.repository.AnnouncementRepository 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( internal class AnnouncementService(
private val announcementRepository: AnnouncementRepository, private val announcementRepository: AnnouncementRepository,
) { ) {
fun latestId(channel: String): APIResponseAnnouncementId? = announcementRepository.latestId(channel) suspend fun latest(tags: Set<String>) = announcementRepository.latest(tags)
fun latestId(): APIResponseAnnouncementId? = announcementRepository.latestId()
fun latest(channel: String) = announcementRepository.latest(channel) suspend fun latest() = announcementRepository.latest()
fun latest() = announcementRepository.latest()
suspend fun all(channel: String) = announcementRepository.all(channel) fun latestId(tags: Set<String>) = announcementRepository.latestId(tags)
suspend fun all() = announcementRepository.all()
suspend fun new(new: APIAnnouncement) { fun latestId() = announcementRepository.latestId()
announcementRepository.new(new)
} suspend fun paged(cursor: Int, limit: Int, tags: Set<String>?) =
suspend fun archive(id: Int, archivedAt: LocalDateTime?) { announcementRepository.paged(cursor, limit, tags)
announcementRepository.archive(id, archivedAt)
} suspend fun get(id: Int) = announcementRepository.get(id)
suspend fun unarchive(id: Int) {
announcementRepository.unarchive(id) suspend fun update(id: Int, new: ApiAnnouncement) = announcementRepository.update(id, new)
}
suspend fun update(id: Int, new: APIAnnouncement) { suspend fun delete(id: Int) = announcementRepository.delete(id)
announcementRepository.update(id, new)
} suspend fun new(new: ApiAnnouncement) = announcementRepository.new(new)
suspend fun delete(id: Int) {
announcementRepository.delete(id) suspend fun tags() = announcementRepository.tags()
}
} }

View File

@ -1,8 +1,9 @@
package app.revanced.api.configuration.services package app.revanced.api.configuration.services
import app.revanced.api.configuration.*
import app.revanced.api.configuration.repository.BackendRepository import app.revanced.api.configuration.repository.BackendRepository
import app.revanced.api.configuration.repository.ConfigurationRepository import app.revanced.api.configuration.repository.ConfigurationRepository
import app.revanced.api.configuration.schema.* import io.ktor.http.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@ -16,12 +17,16 @@ internal class ApiService(
val about = configurationRepository.about val about = configurationRepository.about
suspend fun contributors() = withContext(Dispatchers.IO) { suspend fun contributors() = withContext(Dispatchers.IO) {
configurationRepository.contributorsRepositoryNames.map { configurationRepository.contributorsRepositoryNames.map { (repository, name) ->
async { async {
APIContributable( APIContributable(
it, name,
backendRepository.contributors(configurationRepository.organization, it).map { URLBuilder().apply {
APIContributor(it.name, it.avatarUrl, it.url, it.contributions) takeFrom(backendRepository.website)
path(configurationRepository.organization, repository)
}.buildString(),
backendRepository.contributors(configurationRepository.organization, repository).map {
ApiContributor(it.name, it.avatarUrl, it.url, it.contributions)
}, },
) )
} }
@ -29,13 +34,13 @@ internal class ApiService(
}.awaitAll() }.awaitAll()
suspend fun team() = backendRepository.members(configurationRepository.organization).map { member -> suspend fun team() = backendRepository.members(configurationRepository.organization).map { member ->
APIMember( ApiMember(
member.name, member.name,
member.avatarUrl, member.avatarUrl,
member.url, member.url,
member.bio, member.bio,
if (member.gpgKeys.ids.isNotEmpty()) { if (member.gpgKeys.ids.isNotEmpty()) {
APIGpgKey( ApiGpgKey(
// Must choose one of the GPG keys, because it does not make sense to have multiple GPG keys for the API. // 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.ids.first(),
member.gpgKeys.url, member.gpgKeys.url,
@ -47,6 +52,6 @@ internal class ApiService(
} }
suspend fun rateLimit() = backendRepository.rateLimit()?.let { suspend fun rateLimit() = backendRepository.rateLimit()?.let {
APIRateLimit(it.limit, it.remaining, it.reset) ApiRateLimit(it.limit, it.remaining, it.reset)
} }
} }

View File

@ -1,6 +1,6 @@
package app.revanced.api.configuration.services package app.revanced.api.configuration.services
import app.revanced.api.configuration.schema.APIToken import app.revanced.api.configuration.ApiToken
import com.auth0.jwt.JWT import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.auth.* import io.ktor.server.auth.*
@ -43,7 +43,7 @@ internal class AuthenticationService private constructor(
} }
} }
fun newToken() = APIToken( fun newToken() = ApiToken(
JWT.create() JWT.create()
.withIssuer(issuer) .withIssuer(issuer)
.withExpiresAt(Instant.now().plus(validityInMin, ChronoUnit.MINUTES)) .withExpiresAt(Instant.now().plus(validityInMin, ChronoUnit.MINUTES))

View File

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

View File

@ -1,69 +0,0 @@
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,9 +1,11 @@
package app.revanced.api.configuration.services 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
import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendRelease.Companion.first import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendRelease.Companion.first
import app.revanced.api.configuration.repository.ConfigurationRepository import app.revanced.api.configuration.repository.ConfigurationRepository
import app.revanced.api.configuration.schema.*
import app.revanced.library.serializeTo import app.revanced.library.serializeTo
import app.revanced.patcher.patch.loadPatchesFromJar import app.revanced.patcher.patch.loadPatchesFromJar
import com.github.benmanes.caffeine.cache.Caffeine import com.github.benmanes.caffeine.cache.Caffeine
@ -17,50 +19,28 @@ internal class PatchesService(
private val backendRepository: BackendRepository, private val backendRepository: BackendRepository,
private val configurationRepository: ConfigurationRepository, private val configurationRepository: ConfigurationRepository,
) { ) {
suspend fun latestRelease(): APIRelease<APIPatchesAsset> { suspend fun latestRelease(): ApiRelease {
val patchesRelease = backendRepository.release( val patchesRelease = backendRepository.release(
configurationRepository.organization, configurationRepository.organization,
configurationRepository.patches.repository, configurationRepository.patches.repository,
) )
val integrationsRelease = backendRepository.release( return ApiRelease(
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.tag,
patchesRelease.createdAt, patchesRelease.createdAt,
patchesRelease.releaseNote, patchesRelease.releaseNote,
listOf(patchesAsset, integrationsAsset), patchesRelease.assets.first(configurationRepository.patches.assetRegex).downloadUrl,
patchesRelease.assets.first(configurationRepository.patches.signatureAssetRegex).downloadUrl,
) )
} }
suspend fun latestVersion(): APIReleaseVersion { suspend fun latestVersion(): ApiReleaseVersion {
val patchesRelease = backendRepository.release( val patchesRelease = backendRepository.release(
configurationRepository.organization, configurationRepository.organization,
configurationRepository.patches.repository, configurationRepository.patches.repository,
) )
return APIReleaseVersion(patchesRelease.tag) return ApiReleaseVersion(patchesRelease.tag)
} }
private val patchesListCache = Caffeine private val patchesListCache = Caffeine
@ -111,14 +91,5 @@ internal class PatchesService(
} }
} }
fun publicKeys(): APIAssetPublicKeys { fun publicKey() = ApiAssetPublicKey(configurationRepository.patches.publicKeyFile.readText())
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 { internal class SignatureService {
private val signatureCache = Caffeine private val signatureCache = Caffeine
.newBuilder() .newBuilder()
.maximumSize(2) // Assuming this is enough for patches and integrations. .maximumSize(1) // 1 because currently only the latest patches is needed.
.build<ByteArray, Boolean>() // Hash -> Verified. .build<ByteArray, Boolean>() // Hash -> Verified.
fun verify( fun verify(

View File

@ -0,0 +1,186 @@
package app.revanced.api.configuration.services
import app.revanced.api.configuration.ApiAnnouncement
import app.revanced.api.configuration.repository.AnnouncementRepository
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.toKotlinLocalDateTime
import org.jetbrains.exposed.sql.Database
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
private object AnnouncementServiceTest {
private lateinit var announcementService: AnnouncementService
@JvmStatic
@BeforeAll
fun setUp() {
val database = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false")
announcementService = AnnouncementService(AnnouncementRepository(database))
}
@BeforeEach
fun clear() {
runBlocking {
while (true) {
val latestId = announcementService.latestId() ?: break
announcementService.delete(latestId.id)
}
}
}
@Test
fun `can do basic crud`(): Unit = runBlocking {
announcementService.new(ApiAnnouncement(title = "title"))
val latestId = announcementService.latestId()!!.id
announcementService.update(latestId, ApiAnnouncement(title = "new title"))
assert(announcementService.get(latestId)?.title == "new title")
announcementService.delete(latestId)
assertNull(announcementService.get(latestId))
assertNull(announcementService.latestId())
}
@Test
fun `archiving works properly`() = runBlocking {
announcementService.new(ApiAnnouncement(title = "title"))
val latest = announcementService.latest()!!
assertNull(announcementService.get(latest.id)?.archivedAt)
val updated = ApiAnnouncement(
title = latest.title,
archivedAt = LocalDateTime.now().toKotlinLocalDateTime(),
)
announcementService.update(latest.id, updated)
assertNotNull(announcementService.get(latest.id)?.archivedAt)
return@runBlocking
}
@Test
fun `latest works properly`() = runBlocking {
announcementService.new(ApiAnnouncement(title = "title"))
announcementService.new(ApiAnnouncement(title = "title2"))
var latest = announcementService.latest()
assert(latest?.title == "title2")
announcementService.delete(latest!!.id)
latest = announcementService.latest()
assert(latest?.title == "title")
announcementService.delete(latest!!.id)
assertNull(announcementService.latest())
announcementService.new(ApiAnnouncement(title = "1", tags = listOf("tag1", "tag2")))
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 announcement2and3 = announcementService.latest(setOf("tag1", "tag3"))
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")
announcementService.delete(announcementService.latestId()!!.id)
assert(announcementService.latest(setOf("tag1", "tag3")).first().title == "1")
announcementService.delete(announcementService.latestId()!!.id)
assert(announcementService.latest(setOf("tag1", "tag3")).isEmpty())
assert(announcementService.tags().isEmpty())
}
@Test
fun `tags work properly`() = runBlocking {
announcementService.new(ApiAnnouncement(title = "title", tags = listOf("tag1", "tag2")))
announcementService.new(ApiAnnouncement(title = "title2", tags = listOf("tag1", "tag3")))
val tags = announcementService.tags()
assertEquals(3, tags.size)
assert(tags.any { it.name == "tag1" })
assert(tags.any { it.name == "tag2" })
assert(tags.any { it.name == "tag3" })
announcementService.delete(announcementService.latestId()!!.id)
assertEquals(2, announcementService.tags().size)
announcementService.update(
announcementService.latestId()!!.id,
ApiAnnouncement(title = "title", tags = listOf("tag1", "tag3")),
)
assertEquals(2, announcementService.tags().size)
assert(announcementService.tags().any { it.name == "tag3" })
}
@Test
fun `attachments work properly`() = runBlocking {
announcementService.new(ApiAnnouncement(title = "title", attachments = listOf("attachment1", "attachment2")))
val latestAnnouncement = announcementService.latest()!!
val latestId = latestAnnouncement.id
val attachments = latestAnnouncement.attachments
assertEquals(2, attachments.size)
assert(attachments.any { it == "attachment1" })
assert(attachments.any { it == "attachment2" })
announcementService.update(
latestId,
ApiAnnouncement(title = "title", attachments = listOf("attachment1", "attachment3")),
)
assert(announcementService.get(latestId)!!.attachments.any { it == "attachment3" })
}
@Test
fun `paging works correctly`() = runBlocking {
repeat(10) {
announcementService.new(ApiAnnouncement(title = "title$it"))
}
val announcements = announcementService.paged(Int.MAX_VALUE, 5, null)
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)
assertEquals(5, announcements2.size, "Returns correct number of announcements when starting from the cursor")
assertEquals("title4", announcements2.first().title, "Starts from the cursor")
(0..4).forEach { id ->
announcementService.update(
id,
ApiAnnouncement(
title = "title$id",
tags = (0..id).map { "tag$it" },
archivedAt = if (id % 2 == 0) {
// Only two announcements will be archived.
LocalDateTime.now().plusDays(2).minusDays(id.toLong()).toKotlinLocalDateTime()
} else {
null
},
),
)
}
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))
assertEquals(4, announcements3.size, "Filters announcements by tag")
}
}