diff --git a/.env.example b/.env.example index 1b1b56f..b3921b8 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,6 @@ AUTH_SHA256_DIGEST= JWT_SECRET= JWT_ISSUER= JWT_VALIDITY_IN_MIN= + +# Logging level for the application +LOG_LEVEL=INFO \ No newline at end of file diff --git a/.gitignore b/.gitignore index d376259..c777613 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,6 @@ persistence/ configuration.toml docker-compose.yml patches-public-key.asc -integrations-public-key.asc node_modules/ static/ about.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 65033cc..6a05a0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 1ed9dac..ed9513c 100644 --- a/README.md +++ b/README.md @@ -68,19 +68,19 @@ API server for ReVanced. ## ❓ About ReVanced API is a server that is used as the backend for ReVanced. -ReVanced API acts as the data source for [ReVanced Website](https://github.com/ReVanced/revanced-website) and powers [ReVanced Manager](https://github.com/ReVanced/revanced-manager) +ReVanced API acts as the data source for [ReVanced Website](https://github.com/ReVanced/revanced-website) and +powers [ReVanced Manager](https://github.com/ReVanced/revanced-manager) with updates and ReVanced Patches. ## đŸ’Ē Features Some of the features ReVanced API include: -- đŸ“ĸ **Announcements**: Post and get announcements grouped by channels -- â„šī¸ **About**: Get more information such as a description, ways to donate to, -and links of the hoster of ReVanced API +- đŸ“ĸ **Announcements**: Post and get announcements +- â„šī¸ **About**: Get more information such as a description, ways to donate to, + and links of the hoster of ReVanced API - 🧩 **Patches**: Get the latest updates of ReVanced Patches, directly from ReVanced API - đŸ‘Ĩ **Contributors**: List all contributors involved in the project -- 🔄 **Backwards compatibility**: Proxy an old API for migration purposes and backwards compatibility ## 🚀 How to get started @@ -90,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. 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 @@ -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 \ # Mount the patches public key -v $(pwd)/patches-public-key.asc:/app/patches-public-key.asc \ - # Mount the integrations public key - -v $(pwd)/integrations-public-key.asc:/app/integrations-public-key.asc \ # Mount the static folder -v $(pwd)/static:/app/static \ # Mount the about.json file @@ -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 2. In the same folder, create an `.env` file using [.env.example](.env.example) as a template 3. In the same folder, create a `configuration.toml` file -using [configuration.example.toml](configuration.example.toml) as a template + using [configuration.example.toml](configuration.example.toml) as a template 4. In the same folder, create an `about.json` file using [about.example.json](about.example.json) as a template 5. Run `java -jar revanced-api.jar start` to start the server @@ -159,7 +158,8 @@ A Java Development Kit (JDK) and Git must be installed. ### 📙 Contributing -Thank you for considering contributing to ReVanced API. You can find the contribution guidelines [here](CONTRIBUTING.md). +Thank you for considering contributing to ReVanced API. You can find the contribution +guidelines [here](CONTRIBUTING.md). ### đŸ› ī¸ Building diff --git a/build.gradle.kts b/build.gradle.kts index 0ee566e..0df4c07 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -48,6 +48,12 @@ kotlin { } } +tasks { + test { + useJUnitPlatform() + } +} + repositories { mavenCentral() google() @@ -98,6 +104,8 @@ dependencies { implementation(libs.caffeine) implementation(libs.bouncy.castle.provider) implementation(libs.bouncy.castle.pgp) + + testImplementation(kotlin("test")) } // The maven-publish plugin is necessary to make signing work. diff --git a/configuration.example.toml b/configuration.example.toml index fec352c..71f983b 100644 --- a/configuration.example.toml +++ b/configuration.example.toml @@ -1,22 +1,29 @@ -organization = "revanced" -patches = { repository = "revanced-patches", asset-regex = "jar$", signature-asset-regex = "asc$", public-key-file = "patches-public-key.asc", public-key-id = 0 } -integrations = { repository = "revanced-integrations", asset-regex = "apk$", signature-asset-regex = "asc$", public-key-file = "integrations-public-key.asc", public-key-id = 0 } -manager = { repository = "revanced-manager", asset-regex = "apk$" } -contributors-repositories = [ - "revanced-patcher", - "revanced-patches", - "revanced-integrations", - "revanced-website", - "revanced-cli", - "revanced-manager", -] -api-version = 1 +api-version = "v1" cors-allowed-hosts = [ "revanced.app", "*.revanced.app" ] endpoint = "https://api.revanced.app" -old-api-endpoint = "https://old-api.revanced.app" static-files-path = "static/root" versioned-static-files-path = "static/versioned" +backend-service-name = "GitHub" about-json-file-path = "about.json" +organization = "revanced" + +[patches] +repository = "revanced-patches" +asset-regex = "rvp$" +signature-asset-regex = "asc$" +public-key-file = "static/root/keys.asc" +public-key-id = 3897925568445097277 + +[manager] +repository = "revanced-manager" +asset-regex = "apk$" + +[contributors-repositories] +revanced-patcher = "ReVanced Patcher" +revanced-patches = "ReVanced Patches" +revanced-website = "ReVanced Website" +revanced-cli = "ReVanced CLI" +revanced-manager = "ReVanced Manager" \ No newline at end of file diff --git a/docker-compose.example.yml b/docker-compose.example.yml index db40175..5609eae 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -7,7 +7,6 @@ services: - /data/revanced-api/.env:/app/.env - /data/revanced-api/configuration.toml:/app/configuration.toml - /data/revanced-api/patches-public-key.asc:/app/patches-public-key.asc - - /data/revanced-api/integrations-public-key.asc:/app/integrations-public-key.asc - /data/revanced-api/static:/app/static - /data/revanced-api/about.json:/app/about.json environment: diff --git a/gradle.properties b/gradle.properties index f621b40..01a9c8d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.3.0 +version = 1.4.0-dev.6 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2816c9e..8cb1894 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] kompendium-core = "3.14.4" -kotlin = "2.0.0" +kotlin = "2.0.20" logback = "1.5.6" exposed = "0.52.0" h2 = "2.2.224" @@ -10,8 +10,8 @@ ktor = "2.3.7" ktoml = "0.5.2" picocli = "4.7.6" datetime = "0.6.0" -revanced-patcher = "20.0.0" -revanced-library = "3.0.1-dev.1" +revanced-patcher = "21.0.0" +revanced-library = "3.0.2" caffeine = "3.1.8" bouncy-castle = "1.78.1" diff --git a/src/main/kotlin/app/revanced/api/configuration/schema/APISchema.kt b/src/main/kotlin/app/revanced/api/configuration/APISchema.kt similarity index 74% rename from src/main/kotlin/app/revanced/api/configuration/schema/APISchema.kt rename to src/main/kotlin/app/revanced/api/configuration/APISchema.kt index 6e749c1..c4432ae 100644 --- a/src/main/kotlin/app/revanced/api/configuration/schema/APISchema.kt +++ b/src/main/kotlin/app/revanced/api/configuration/APISchema.kt @@ -1,124 +1,112 @@ -package app.revanced.api.configuration.schema +package app.revanced.api.configuration import kotlinx.datetime.LocalDateTime import kotlinx.serialization.Serializable -interface APIUser { +interface ApiUser { val name: String val avatarUrl: String val url: String } @Serializable -class APIMember( +class ApiMember( override val name: String, override val avatarUrl: String, override val url: String, val bio: String?, - val gpgKey: APIGpgKey?, -) : APIUser + val gpgKey: ApiGpgKey?, +) : ApiUser @Serializable -class APIGpgKey( +class ApiGpgKey( val id: String, val url: String, ) @Serializable -class APIContributor( +class ApiContributor( override val name: String, override val avatarUrl: String, override val url: String, val contributions: Int, -) : APIUser +) : ApiUser @Serializable class APIContributable( val name: String, + val url: String, // Using a list instead of a set because set semantics are unnecessary here. - val contributors: List, + val contributors: List, ) @Serializable -class APIRelease( +class ApiRelease( val version: String, val createdAt: LocalDateTime, val description: String, - // Using a list instead of a set because set semantics are unnecessary here. - val assets: List, -) - -@Serializable -class APIManagerAsset( val downloadUrl: String, + val signatureDownloadUrl: String? = null, ) @Serializable -class APIPatchesAsset( - val downloadUrl: String, - val signatureDownloadUrl: String, - // TODO: Remove this eventually when integrations are merged into patches. - val name: APIAssetName, -) - -@Serializable -enum class APIAssetName { - PATCHES, - INTEGRATION, -} - -@Serializable -class APIReleaseVersion( +class ApiReleaseVersion( val version: String, ) @Serializable -class APIAnnouncement( +class ApiAnnouncement( val author: String? = null, val title: String, val content: String? = null, // Using a list instead of a set because set semantics are unnecessary here. - val attachmentUrls: List = emptyList(), - val channel: String? = null, + val attachments: List = emptyList(), + // Using a list instead of a set because set semantics are unnecessary here. + val tags: List = emptyList(), val archivedAt: LocalDateTime? = null, val level: Int = 0, ) @Serializable -class APIResponseAnnouncement( +class ApiResponseAnnouncement( val id: Int, val author: String? = null, val title: String, val content: String? = null, // Using a list instead of a set because set semantics are unnecessary here. - val attachmentUrls: List = emptyList(), - val channel: String? = null, + val attachments: List = emptyList(), + // Using a list instead of a set because set semantics are unnecessary here. + val tags: List = emptyList(), val createdAt: LocalDateTime, val archivedAt: LocalDateTime? = null, val level: Int = 0, ) @Serializable -class APIResponseAnnouncementId( +class ApiResponseAnnouncementId( val id: Int, ) @Serializable -class APIAnnouncementArchivedAt( +class ApiAnnouncementArchivedAt( val archivedAt: LocalDateTime, ) @Serializable -class APIRateLimit( +class ApiAnnouncementTag( + val name: String, +) + +@Serializable +class ApiRateLimit( val limit: Int, val remaining: Int, val reset: LocalDateTime, ) @Serializable -class APIAssetPublicKeys( +class ApiAssetPublicKey( val patchesPublicKey: String, - val integrationsPublicKey: String, ) @Serializable @@ -174,4 +162,4 @@ class APIAbout( } @Serializable -class APIToken(val token: String) +class ApiToken(val token: String) diff --git a/src/main/kotlin/app/revanced/api/configuration/Dependencies.kt b/src/main/kotlin/app/revanced/api/configuration/Dependencies.kt index 451ae73..630cf74 100644 --- a/src/main/kotlin/app/revanced/api/configuration/Dependencies.kt +++ b/src/main/kotlin/app/revanced/api/configuration/Dependencies.kt @@ -5,101 +5,39 @@ import app.revanced.api.configuration.repository.BackendRepository import app.revanced.api.configuration.repository.ConfigurationRepository import app.revanced.api.configuration.repository.GitHubBackendRepository import app.revanced.api.configuration.services.* -import app.revanced.api.configuration.services.AnnouncementService -import app.revanced.api.configuration.services.ApiService -import app.revanced.api.configuration.services.AuthenticationService -import app.revanced.api.configuration.services.OldApiService -import app.revanced.api.configuration.services.PatchesService import com.akuleshov7.ktoml.Toml import com.akuleshov7.ktoml.source.decodeFromStream -import io.ktor.client.* -import io.ktor.client.engine.okhttp.* -import io.ktor.client.plugins.* -import io.ktor.client.plugins.auth.* -import io.ktor.client.plugins.auth.providers.* -import io.ktor.client.plugins.cache.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.plugins.resources.* -import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonNamingStrategy import org.jetbrains.exposed.sql.Database -import org.jetbrains.exposed.sql.transactions.TransactionManager import org.koin.core.module.dsl.singleOf -import org.koin.core.parameter.parameterArrayOf import org.koin.dsl.module import org.koin.ktor.plugin.Koin import java.io.File -@OptIn(ExperimentalSerializationApi::class) fun Application.configureDependencies( configFile: File, ) { - val miscModule = module { - factory { params -> - val defaultRequestUri: String = params.get() - val configBlock = params.getOrNull<(HttpClientConfig.() -> Unit)>() ?: {} - - HttpClient(OkHttp) { - defaultRequest { url(defaultRequestUri) } - - configBlock() - } - } - } - val repositoryModule = module { - single { - GitHubBackendRepository( - get { - val defaultRequestUri = "https://api.github.com" - val configBlock: HttpClientConfig.() -> 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 { - Toml.decodeFromStream(configFile.inputStream()) - } - + single { Toml.decodeFromStream(configFile.inputStream()) } single { - TransactionManager.defaultDatabase = Database.connect( + Database.connect( url = System.getProperty("DB_URL"), user = System.getProperty("DB_USER"), password = System.getProperty("DB_PASSWORD"), ) + } + singleOf(::AnnouncementRepository) + singleOf(::GitHubBackendRepository) + single { + val backendServices = mapOf( + GitHubBackendRepository.SERVICE_NAME to { get() }, + // Implement more backend services here. + ) - AnnouncementRepository() + val configuration = get() + val backendFactory = backendServices[configuration.backendServiceName]!! + + backendFactory() } } @@ -113,15 +51,6 @@ fun Application.configureDependencies( AuthenticationService(issuer, validityInMin, jwtSecret, authSHA256DigestString) } - single { - val configuration = get() - - OldApiService( - get { - parameterArrayOf(configuration.oldApiEndpoint) - }, - ) - } singleOf(::AnnouncementService) singleOf(::SignatureService) singleOf(::PatchesService) @@ -131,7 +60,6 @@ fun Application.configureDependencies( install(Koin) { modules( - miscModule, repositoryModule, serviceModule, ) diff --git a/src/main/kotlin/app/revanced/api/configuration/Routing.kt b/src/main/kotlin/app/revanced/api/configuration/Routing.kt index 1e0f467..da1af7c 100644 --- a/src/main/kotlin/app/revanced/api/configuration/Routing.kt +++ b/src/main/kotlin/app/revanced/api/configuration/Routing.kt @@ -4,7 +4,6 @@ import app.revanced.api.configuration.repository.ConfigurationRepository import app.revanced.api.configuration.routes.* import app.revanced.api.configuration.routes.announcementsRoute import app.revanced.api.configuration.routes.apiRoute -import app.revanced.api.configuration.routes.oldApiRoute import app.revanced.api.configuration.routes.patchesRoute import io.bkbn.kompendium.core.routes.redoc import io.bkbn.kompendium.core.routes.swagger @@ -19,7 +18,7 @@ internal fun Application.configureRouting() = routing { installCache(5.minutes) - route("/v${configuration.apiVersion}") { + route("/${configuration.apiVersion}") { announcementsRoute() patchesRoute() managerRoute() @@ -55,7 +54,4 @@ internal fun Application.configureRouting() = routing { swagger(pageTitle = "ReVanced API", path = "/") redoc(pageTitle = "ReVanced API", path = "/redoc") - - // TODO: Remove, once migration period from v2 API is over (In 1-2 years). - oldApiRoute() } diff --git a/src/main/kotlin/app/revanced/api/configuration/repository/AnnouncementRepository.kt b/src/main/kotlin/app/revanced/api/configuration/repository/AnnouncementRepository.kt index 992b06f..01d74e2 100644 --- a/src/main/kotlin/app/revanced/api/configuration/repository/AnnouncementRepository.kt +++ b/src/main/kotlin/app/revanced/api/configuration/repository/AnnouncementRepository.kt @@ -1,12 +1,11 @@ package app.revanced.api.configuration.repository -import app.revanced.api.configuration.schema.APIAnnouncement -import app.revanced.api.configuration.schema.APIResponseAnnouncement -import app.revanced.api.configuration.schema.APIResponseAnnouncementId +import app.revanced.api.configuration.ApiAnnouncement +import app.revanced.api.configuration.ApiAnnouncementTag +import app.revanced.api.configuration.ApiResponseAnnouncement +import app.revanced.api.configuration.ApiResponseAnnouncementId import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking -import kotlinx.datetime.* import org.jetbrains.exposed.dao.IntEntity import org.jetbrains.exposed.dao.IntEntityClass import org.jetbrains.exposed.dao.id.EntityID @@ -15,136 +14,172 @@ import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.kotlin.datetime.CurrentDateTime import org.jetbrains.exposed.sql.kotlin.datetime.datetime import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction -import org.jetbrains.exposed.sql.transactions.experimental.suspendedTransactionAsync -internal class AnnouncementRepository { - // This is better than doing a maxByOrNull { it.id }. +internal class AnnouncementRepository(private val database: Database) { + // This is better than doing a maxByOrNull { it.id } on every request. private var latestAnnouncement: Announcement? = null - private val latestAnnouncementByChannel = mutableMapOf() - - private fun updateLatestAnnouncement(new: Announcement) { - if (latestAnnouncement?.id?.value == new.id.value) { - latestAnnouncement = new - latestAnnouncementByChannel[new.channel ?: return] = new - } - } + private val latestAnnouncementByTag = mutableMapOf() init { runBlocking { transaction { - SchemaUtils.create(Announcements, Attachments) + SchemaUtils.create( + Announcements, + Attachments, + Tags, + AnnouncementTags, + ) - // Initialize the latest announcement. - latestAnnouncement = Announcement.all().onEach { - latestAnnouncementByChannel[it.channel ?: return@onEach] = it - }.maxByOrNull { it.id } ?: return@transaction + initializeLatestAnnouncements() } } } - suspend fun all() = transaction { - Announcement.all().map { it.toApi() } + private fun initializeLatestAnnouncements() { + latestAnnouncement = Announcement.all().orderBy(Announcements.id to SortOrder.DESC).firstOrNull() + + Tag.all().map { it.name }.forEach(::updateLatestAnnouncementForTag) } - suspend fun all(channel: String) = transaction { - Announcement.find { Announcements.channel eq channel }.map { it.toApi() } + private fun updateLatestAnnouncement(new: Announcement) { + 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) = transaction { + tags.mapNotNull { tag -> latestAnnouncementByTag[tag] }.toApiAnnouncement() + } + + fun latestId() = latestAnnouncement?.id?.value.toApiResponseAnnouncementId() + + fun latestId(tags: Set) = + tags.map { tag -> latestAnnouncementByTag[tag]?.id?.value }.toApiResponseAnnouncementId() + + suspend fun paged(cursor: Int, count: Int, tags: Set?) = 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 { 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() - // 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) { - 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. - if (latestAnnouncement == null) { - latestAnnouncementByChannel.remove(announcement.channel) - } else { - latestAnnouncementByChannel[latestAnnouncement!!.channel ?: return@transaction] = latestAnnouncement!! - } + // The new announcement may be the latest for a specific tag. Set the new latest announcement for that tag. + latestAnnouncementByTag.keys.forEach { tag -> + updateLatestAnnouncementForTag(tag) } } - fun latest() = latestAnnouncement?.toApi() - - fun latest(channel: String) = latestAnnouncementByChannel[channel]?.toApi() - - fun latestId() = latest()?.id?.toApi() - - fun latestId(channel: String) = latest(channel)?.id?.toApi() - - suspend fun archive( - id: Int, - archivedAt: LocalDateTime?, - ) = transaction { - Announcement.findByIdAndUpdate(id) { - it.archivedAt = archivedAt ?: java.time.LocalDateTime.now().toKotlinLocalDateTime() - }?.also(::updateLatestAnnouncement) - } - - suspend fun unarchive(id: Int) = transaction { - Announcement.findByIdAndUpdate(id) { - it.archivedAt = null - }?.also(::updateLatestAnnouncement) - } - - suspend fun new(new: APIAnnouncement) = transaction { - Announcement.new { - author = new.author - title = new.title - content = new.content - channel = new.channel - archivedAt = new.archivedAt - level = new.level - }.also { newAnnouncement -> - new.attachmentUrls.map { newUrl -> - suspendedTransactionAsync { - Attachment.new { - url = newUrl - announcement = newAnnouncement - } - } - }.awaitAll() - }.also(::updateLatestAnnouncement) - } - - suspend fun update(id: Int, new: APIAnnouncement) = transaction { - Announcement.findByIdAndUpdate(id) { - it.author = new.author - it.title = new.title - it.content = new.content - it.channel = new.channel - it.archivedAt = new.archivedAt - it.level = new.level - }?.also { newAnnouncement -> - newAnnouncement.attachments.map { - suspendedTransactionAsync { - it.delete() - } - }.awaitAll() - - new.attachmentUrls.map { newUrl -> - suspendedTransactionAsync { - Attachment.new { - url = newUrl - announcement = newAnnouncement - } - } - }.awaitAll() - }?.also(::updateLatestAnnouncement) + suspend fun tags() = transaction { + Tag.all().toList().toApiTag() } private suspend fun transaction(statement: suspend Transaction.() -> T) = - newSuspendedTransaction(Dispatchers.IO, statement = statement) + newSuspendedTransaction(Dispatchers.IO, database, statement = statement) private object Announcements : IntIdTable() { val author = varchar("author", 32).nullable() val title = varchar("title", 64) val content = text("content").nullable() - val channel = varchar("channel", 16).nullable() val createdAt = datetime("createdAt").defaultExpression(CurrentDateTime) val archivedAt = datetime("archivedAt").nullable() val level = integer("level") @@ -155,6 +190,19 @@ internal class AnnouncementRepository { 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) : IntEntity(id) { companion object : IntEntityClass(Announcements) @@ -162,7 +210,7 @@ internal class AnnouncementRepository { var title by Announcements.title var content by Announcements.content val attachments by Attachment referrersOn Attachments.announcement - var channel by Announcements.channel + var tags by Tag via AnnouncementTags var createdAt by Announcements.createdAt var archivedAt by Announcements.archivedAt var level by Announcements.level @@ -175,17 +223,32 @@ internal class AnnouncementRepository { var announcement by Announcement referencedOn Attachments.announcement } - private fun Announcement.toApi() = APIResponseAnnouncement( - id.value, - author, - title, - content, - attachments.map { it.url }, - channel, - createdAt, - archivedAt, - level, - ) + class Tag(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Tags) - 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.toApiAnnouncement() = map { it.toApiResponseAnnouncement()!! } + + private fun Iterable.toApiTag() = map { ApiAnnouncementTag(it.name) } + + private fun Int?.toApiResponseAnnouncementId() = this?.let { ApiResponseAnnouncementId(this) } + + private fun Iterable.toApiResponseAnnouncementId() = map { it.toApiResponseAnnouncementId() } } diff --git a/src/main/kotlin/app/revanced/api/configuration/repository/BackendRepository.kt b/src/main/kotlin/app/revanced/api/configuration/repository/BackendRepository.kt index 69429ca..85d10bb 100644 --- a/src/main/kotlin/app/revanced/api/configuration/repository/BackendRepository.kt +++ b/src/main/kotlin/app/revanced/api/configuration/repository/BackendRepository.kt @@ -1,16 +1,59 @@ package app.revanced.api.configuration.repository import io.ktor.client.* +import io.ktor.client.engine.okhttp.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.auth.* +import io.ktor.client.plugins.auth.providers.* +import io.ktor.client.plugins.cache.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.resources.* +import io.ktor.serialization.kotlinx.json.* import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNamingStrategy /** * The backend of the API used to get data. * - * @param 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( - 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. * @@ -153,7 +196,10 @@ abstract class BackendRepository internal constructor( * @param repository The name of the repository. * @return The contributors. */ - abstract suspend fun contributors(owner: String, repository: String): List + abstract suspend fun contributors( + owner: String, + repository: String, + ): List /** * Get the members of an organization. diff --git a/src/main/kotlin/app/revanced/api/configuration/repository/ConfigurationRepository.kt b/src/main/kotlin/app/revanced/api/configuration/repository/ConfigurationRepository.kt index 65b82f6..4c46887 100644 --- a/src/main/kotlin/app/revanced/api/configuration/repository/ConfigurationRepository.kt +++ b/src/main/kotlin/app/revanced/api/configuration/repository/ConfigurationRepository.kt @@ -1,6 +1,6 @@ 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.PatchesService import kotlinx.serialization.ExperimentalSerializationApi @@ -22,15 +22,14 @@ import kotlin.io.path.createDirectories /** * 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 integrations The source of the integrations. * @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 corsAllowedHosts The hosts allowed to make requests to the API. * @property endpoint The endpoint of the API. - * @property oldApiEndpoint The endpoint of the old API to proxy requests to. * @property staticFilesPath The path to the static files to be served under the root path. * @property versionedStaticFilesPath The path to the static files to be served under a versioned path. * @property about The path to the json file deserialized to [APIAbout] @@ -40,17 +39,16 @@ import kotlin.io.path.createDirectories internal class ConfigurationRepository( val organization: String, val patches: SignedAssetConfiguration, - val integrations: SignedAssetConfiguration, val manager: AssetConfiguration, @SerialName("contributors-repositories") - val contributorsRepositoryNames: Set, + val contributorsRepositoryNames: Map, + @SerialName("backend-service-name") + val backendServiceName: String, @SerialName("api-version") - val apiVersion: Int = 1, + val apiVersion: String = "v1", @SerialName("cors-allowed-hosts") val corsAllowedHosts: Set, val endpoint: String, - @SerialName("old-api-endpoint") - val oldApiEndpoint: String, @Serializable(with = PathSerializer::class) @SerialName("static-files-path") val staticFilesPath: Path, diff --git a/src/main/kotlin/app/revanced/api/configuration/repository/GitHubBackendRepository.kt b/src/main/kotlin/app/revanced/api/configuration/repository/GitHubBackendRepository.kt index 39d0236..420ccaf 100644 --- a/src/main/kotlin/app/revanced/api/configuration/repository/GitHubBackendRepository.kt +++ b/src/main/kotlin/app/revanced/api/configuration/repository/GitHubBackendRepository.kt @@ -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.Organization.Repository.Contributors import app.revanced.api.configuration.repository.Organization.Repository.Releases -import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.plugins.resources.* import io.ktor.resources.* -import kotlinx.coroutines.* +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -class GitHubBackendRepository(client: HttpClient) : BackendRepository(client) { +class GitHubBackendRepository : BackendRepository("https://api.github.com", "https://github.com") { override suspend fun release( owner: String, repository: String, @@ -67,7 +68,8 @@ class GitHubBackendRepository(client: HttpClient) : BackendRepository(client) { override suspend fun members(organization: String): List { // Get the list of members of the organization. - val publicMembers: List = client.get(Organization.PublicMembers(organization)).body() + val publicMembers: List = + client.get(Organization.PublicMembers(organization)).body() return coroutineScope { publicMembers.map { member -> @@ -113,6 +115,10 @@ class GitHubBackendRepository(client: HttpClient) : BackendRepository(client) { reset = Instant.fromEpochSeconds(rateLimit.rate.reset).toLocalDateTime(TimeZone.UTC), ) } + + companion object { + const val SERVICE_NAME = "GitHub" + } } interface IGitHubUser { diff --git a/src/main/kotlin/app/revanced/api/configuration/routes/Announcements.kt b/src/main/kotlin/app/revanced/api/configuration/routes/Announcements.kt index 2f2ffd8..bf759fe 100644 --- a/src/main/kotlin/app/revanced/api/configuration/routes/Announcements.kt +++ b/src/main/kotlin/app/revanced/api/configuration/routes/Announcements.kt @@ -1,22 +1,23 @@ package app.revanced.api.configuration.routes +import app.revanced.api.configuration.ApiAnnouncement +import app.revanced.api.configuration.ApiResponseAnnouncement +import app.revanced.api.configuration.ApiResponseAnnouncementId import app.revanced.api.configuration.canRespondUnauthorized import app.revanced.api.configuration.installCache import app.revanced.api.configuration.installNotarizedRoute import app.revanced.api.configuration.respondOrNotFound -import app.revanced.api.configuration.schema.APIAnnouncement -import app.revanced.api.configuration.schema.APIAnnouncementArchivedAt -import app.revanced.api.configuration.schema.APIResponseAnnouncement -import app.revanced.api.configuration.schema.APIResponseAnnouncementId import app.revanced.api.configuration.services.AnnouncementService -import io.bkbn.kompendium.core.metadata.* +import io.bkbn.kompendium.core.metadata.DeleteInfo +import io.bkbn.kompendium.core.metadata.GetInfo +import io.bkbn.kompendium.core.metadata.PatchInfo +import io.bkbn.kompendium.core.metadata.PostInfo import io.bkbn.kompendium.json.schema.definition.TypeDefinition import io.bkbn.kompendium.oas.payload.Parameter import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.server.plugins.ratelimit.* -import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.util.* @@ -32,76 +33,62 @@ internal fun Route.announcementsRoute() = route("announcements") { rateLimit(RateLimitName("strong")) { 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")) { - 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")) { + rateLimit(RateLimitName("weak")) { authenticate("jwt") { - installAnnouncementRouteDocumentation() - - post { announcement -> + post { announcement -> announcementService.new(announcement) call.respond(HttpStatusCode.OK) } + } - route("{id}") { - installAnnouncementIdRouteDocumentation() + route("latest") { + installAnnouncementsLatestRouteDocumentation() - patch { 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 { announcement -> val id: Int by call.parameters announcementService.update(id, announcement) @@ -116,31 +103,14 @@ internal fun Route.announcementsRoute() = route("announcements") { call.respond(HttpStatusCode.OK) } + } + } - route("archive") { - installAnnouncementArchiveRouteDocumentation() + route("tags") { + installAnnouncementsTagsRouteDocumentation() - post { - val id: Int by call.parameters - val archivedAt = call.receiveNullable()?.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) - } - } + get { + call.respond(announcementService.tags()) } } } @@ -154,16 +124,49 @@ private val authHeaderParameter = Parameter( examples = mapOf("Bearer authentication" to Parameter.Example("Bearer abc123")), ) -private fun Route.installAnnouncementRouteDocumentation() = installNotarizedRoute { +private fun Route.installAnnouncementsRouteDocumentation() = installNotarizedRoute { 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>() + } + } post = PostInfo.builder { description("Create a new announcement") summary("Create announcement") + parameters(authHeaderParameter) request { - requestType() + requestType() description("The new announcement") } response { @@ -175,17 +178,32 @@ private fun Route.installAnnouncementRouteDocumentation() = installNotarizedRout } } -private fun Route.installLatestAnnouncementRouteDocumentation() = installNotarizedRoute { +private fun Route.installAnnouncementsLatestRouteDocumentation() = installNotarizedRoute { tags = setOf("Announcements") get = GetInfo.builder { description("Get the 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 { responseCode(HttpStatusCode.OK) mediaTypes("application/json") description("The latest announcement") - responseType() + responseType() + } + canRespond { + responseCode(HttpStatusCode.OK) + mediaTypes("application/json") + description("The latest announcements") + responseType>() } canRespond { 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") get = GetInfo.builder { - description("Get the id of the latest announcement") - summary("Get id of latest announcement") + description("Get the ID of the 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 { responseCode(HttpStatusCode.OK) mediaTypes("application/json") - description("The id of the latest announcement") - responseType() + description("The ID of the latest announcement") + responseType() + } + canRespond { + responseCode(HttpStatusCode.OK) + mediaTypes("application/json") + description("The IDs of the latest announcements") + responseType>() } canRespond { 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") parameters = listOf( Parameter( - name = "channel", + name = "id", `in` = Parameter.Location.path, - schema = TypeDefinition.STRING, - description = "The channel to get the announcements from", + schema = TypeDefinition.INT, + description = "The ID of the announcement to update", required = true, ), + authHeaderParameter, ) get = GetInfo.builder { - description("Get the announcements from a channel") - summary("Get announcements from channel") + description("Get an announcement") + summary("Get announcement") response { + description("The announcement") responseCode(HttpStatusCode.OK) - mediaTypes("application/json") - description("The announcements in the channel") - responseType>() + responseType() } - } -} - -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) + canRespond { + responseCode(HttpStatusCode.NotFound) + description("The announcement does not exist") responseType() } - 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() - } - 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 { description("Update an announcement") summary("Update announcement") request { - requestType() + requestType() description("The new announcement") } response { @@ -340,77 +304,17 @@ private fun Route.installAnnouncementIdRouteDocumentation() = installNotarizedRo } } -private fun Route.installAnnouncementsRouteDocumentation() = installNotarizedRoute { +private fun Route.installAnnouncementsTagsRouteDocumentation() = installNotarizedRoute { tags = setOf("Announcements") get = GetInfo.builder { - description("Get the announcements") - summary("Get announcements") + description("Get all announcement tags") + summary("Get announcement tags") response { responseCode(HttpStatusCode.OK) mediaTypes("application/json") - description("The announcements") - responseType>() - } - } -} - -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() - } - canRespond { - responseCode(HttpStatusCode.NotFound) - description("The channel does not exist") - responseType() - } - } -} - -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() - } - canRespond { - responseCode(HttpStatusCode.NotFound) - description("The channel does not exist") - responseType() + description("The announcement tags") + responseType>() } } } diff --git a/src/main/kotlin/app/revanced/api/configuration/routes/ApiRoute.kt b/src/main/kotlin/app/revanced/api/configuration/routes/ApiRoute.kt index 2296fee..f5311b1 100644 --- a/src/main/kotlin/app/revanced/api/configuration/routes/ApiRoute.kt +++ b/src/main/kotlin/app/revanced/api/configuration/routes/ApiRoute.kt @@ -6,7 +6,6 @@ import app.revanced.api.configuration.installNoCache import app.revanced.api.configuration.installNotarizedRoute import app.revanced.api.configuration.repository.ConfigurationRepository import app.revanced.api.configuration.respondOrNotFound -import app.revanced.api.configuration.schema.* import app.revanced.api.configuration.services.ApiService import app.revanced.api.configuration.services.AuthenticationService import io.bkbn.kompendium.core.metadata.* @@ -115,7 +114,7 @@ private fun Route.installRateLimitRouteDocumentation() = installNotarizedRoute { description("The rate limit of the backend") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType() + responseType() } } } @@ -144,7 +143,7 @@ private fun Route.installTeamRouteDocumentation() = installNotarizedRoute { description("The list of team members") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType>() + responseType>() } } } @@ -184,7 +183,7 @@ private fun Route.installTokenRouteDocumentation() = installNotarizedRoute { "username=\"ReVanced\", " + "realm=\"ReVanced\", " + "nonce=\"abc123\", " + - "uri=\"/v${configuration.apiVersion}/token\", " + + "uri=\"/${configuration.apiVersion}/token\", " + "algorithm=SHA-256, " + "response=\"yxz456\"", ), @@ -195,7 +194,7 @@ private fun Route.installTokenRouteDocumentation() = installNotarizedRoute { description("The authorization token") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType() + responseType() } canRespondUnauthorized() } diff --git a/src/main/kotlin/app/revanced/api/configuration/routes/ManagerRoute.kt b/src/main/kotlin/app/revanced/api/configuration/routes/ManagerRoute.kt index 811f3a9..c82c0fb 100644 --- a/src/main/kotlin/app/revanced/api/configuration/routes/ManagerRoute.kt +++ b/src/main/kotlin/app/revanced/api/configuration/routes/ManagerRoute.kt @@ -1,9 +1,8 @@ package app.revanced.api.configuration.routes +import app.revanced.api.configuration.ApiRelease +import app.revanced.api.configuration.ApiReleaseVersion import app.revanced.api.configuration.installNotarizedRoute -import app.revanced.api.configuration.schema.APIManagerAsset -import app.revanced.api.configuration.schema.APIRelease -import app.revanced.api.configuration.schema.APIReleaseVersion import app.revanced.api.configuration.services.ManagerService import io.bkbn.kompendium.core.metadata.GetInfo import io.ktor.http.* @@ -14,18 +13,9 @@ import io.ktor.server.routing.* import org.koin.ktor.ext.get as koinGet internal fun Route.managerRoute() = route("manager") { - configure() - - // TODO: Remove this deprecated route eventually. - route("latest") { - configure(deprecated = true) - } -} - -private fun Route.configure(deprecated: Boolean = false) { val managerService = koinGet() - installManagerRouteDocumentation(deprecated) + installManagerRouteDocumentation() rateLimit(RateLimitName("weak")) { get { @@ -33,7 +23,7 @@ private fun Route.configure(deprecated: Boolean = false) { } route("version") { - installManagerVersionRouteDocumentation(deprecated) + installManagerVersionRouteDocumentation() get { 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") get = GetInfo.builder { - if (deprecated) isDeprecated() description("Get the current manager release") summary("Get current manager release") response { description("The latest manager release") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType>() + responseType() } } } -private fun Route.installManagerVersionRouteDocumentation(deprecated: Boolean) = installNotarizedRoute { +private fun Route.installManagerVersionRouteDocumentation() = installNotarizedRoute { tags = setOf("Manager") get = GetInfo.builder { - if (deprecated) isDeprecated() description("Get the current manager release version") summary("Get current manager release version") response { description("The current manager release version") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType() + responseType() } } } diff --git a/src/main/kotlin/app/revanced/api/configuration/routes/OldApi.kt b/src/main/kotlin/app/revanced/api/configuration/routes/OldApi.kt deleted file mode 100644 index e72f470..0000000 --- a/src/main/kotlin/app/revanced/api/configuration/routes/OldApi.kt +++ /dev/null @@ -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() - - rateLimit(RateLimitName("weak")) { - route(Regex("/(v2|tools|contributors).*")) { - handle { - oldApiService.proxy(call) - } - } - } -} diff --git a/src/main/kotlin/app/revanced/api/configuration/routes/PatchesRoute.kt b/src/main/kotlin/app/revanced/api/configuration/routes/PatchesRoute.kt index efe7e10..9672d9e 100644 --- a/src/main/kotlin/app/revanced/api/configuration/routes/PatchesRoute.kt +++ b/src/main/kotlin/app/revanced/api/configuration/routes/PatchesRoute.kt @@ -1,11 +1,10 @@ package app.revanced.api.configuration.routes +import app.revanced.api.configuration.ApiAssetPublicKey +import app.revanced.api.configuration.ApiRelease +import app.revanced.api.configuration.ApiReleaseVersion import app.revanced.api.configuration.installCache import app.revanced.api.configuration.installNotarizedRoute -import app.revanced.api.configuration.schema.APIAssetPublicKeys -import app.revanced.api.configuration.schema.APIPatchesAsset -import app.revanced.api.configuration.schema.APIRelease -import app.revanced.api.configuration.schema.APIReleaseVersion import app.revanced.api.configuration.services.PatchesService import io.bkbn.kompendium.core.metadata.GetInfo import io.ktor.http.* @@ -17,18 +16,9 @@ import kotlin.time.Duration.Companion.days import org.koin.ktor.ext.get as koinGet internal fun Route.patchesRoute() = route("patches") { - configure() - - // TODO: Remove this deprecated route eventually. - route("latest") { - configure(deprecated = true) - } -} - -private fun Route.configure(deprecated: Boolean = false) { val patchesService = koinGet() - installPatchesRouteDocumentation(deprecated) + installPatchesRouteDocumentation() rateLimit(RateLimitName("weak")) { get { @@ -36,7 +26,7 @@ private fun Route.configure(deprecated: Boolean = false) { } route("version") { - installPatchesVersionRouteDocumentation(deprecated) + installPatchesVersionRouteDocumentation() get { call.respond(patchesService.latestVersion()) @@ -46,7 +36,7 @@ private fun Route.configure(deprecated: Boolean = false) { rateLimit(RateLimitName("strong")) { route("list") { - installPatchesListRouteDocumentation(deprecated) + installPatchesListRouteDocumentation() get { call.respondBytes(ContentType.Application.Json) { patchesService.list() } @@ -58,52 +48,49 @@ private fun Route.configure(deprecated: Boolean = false) { route("keys") { installCache(356.days) - installPatchesPublicKeyRouteDocumentation(deprecated) + installPatchesPublicKeyRouteDocumentation() get { - call.respond(patchesService.publicKeys()) + call.respond(patchesService.publicKey()) } } } } -private fun Route.installPatchesRouteDocumentation(deprecated: Boolean) = installNotarizedRoute { +private fun Route.installPatchesRouteDocumentation() = installNotarizedRoute { tags = setOf("Patches") get = GetInfo.builder { - if (deprecated) isDeprecated() description("Get the current patches release") summary("Get current patches release") response { description("The current patches release") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType>() + responseType() } } } -private fun Route.installPatchesVersionRouteDocumentation(deprecated: Boolean) = installNotarizedRoute { +private fun Route.installPatchesVersionRouteDocumentation() = installNotarizedRoute { tags = setOf("Patches") get = GetInfo.builder { - if (deprecated) isDeprecated() description("Get the current patches release version") summary("Get current patches release version") response { description("The current patches release version") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType() + responseType() } } } -private fun Route.installPatchesListRouteDocumentation(deprecated: Boolean) = installNotarizedRoute { +private fun Route.installPatchesListRouteDocumentation() = installNotarizedRoute { tags = setOf("Patches") get = GetInfo.builder { - if (deprecated) isDeprecated() description("Get the list of patches from the current patches release") summary("Get list of patches from current patches release") 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") get = GetInfo.builder { - if (deprecated) isDeprecated() - description("Get the public keys for verifying patches and integrations assets") - summary("Get patches and integrations public keys") + description("Get the public keys for verifying patches assets") + summary("Get patches public keys") response { description("The public keys") mediaTypes("application/json") responseCode(HttpStatusCode.OK) - responseType() + responseType() } } } diff --git a/src/main/kotlin/app/revanced/api/configuration/services/AnnouncementService.kt b/src/main/kotlin/app/revanced/api/configuration/services/AnnouncementService.kt index 909cff8..b32d9f9 100644 --- a/src/main/kotlin/app/revanced/api/configuration/services/AnnouncementService.kt +++ b/src/main/kotlin/app/revanced/api/configuration/services/AnnouncementService.kt @@ -1,35 +1,29 @@ package app.revanced.api.configuration.services +import app.revanced.api.configuration.ApiAnnouncement import app.revanced.api.configuration.repository.AnnouncementRepository -import app.revanced.api.configuration.schema.APIAnnouncement -import app.revanced.api.configuration.schema.APIResponseAnnouncementId -import kotlinx.datetime.LocalDateTime internal class AnnouncementService( private val announcementRepository: AnnouncementRepository, ) { - fun latestId(channel: String): APIResponseAnnouncementId? = announcementRepository.latestId(channel) - fun latestId(): APIResponseAnnouncementId? = announcementRepository.latestId() + suspend fun latest(tags: Set) = announcementRepository.latest(tags) - fun latest(channel: String) = announcementRepository.latest(channel) - fun latest() = announcementRepository.latest() + suspend fun latest() = announcementRepository.latest() - suspend fun all(channel: String) = announcementRepository.all(channel) - suspend fun all() = announcementRepository.all() + fun latestId(tags: Set) = announcementRepository.latestId(tags) - suspend fun new(new: APIAnnouncement) { - announcementRepository.new(new) - } - suspend fun archive(id: Int, archivedAt: LocalDateTime?) { - announcementRepository.archive(id, archivedAt) - } - suspend fun unarchive(id: Int) { - announcementRepository.unarchive(id) - } - suspend fun update(id: Int, new: APIAnnouncement) { - announcementRepository.update(id, new) - } - suspend fun delete(id: Int) { - announcementRepository.delete(id) - } + fun latestId() = announcementRepository.latestId() + + suspend fun paged(cursor: Int, limit: Int, tags: Set?) = + announcementRepository.paged(cursor, limit, tags) + + suspend fun get(id: Int) = announcementRepository.get(id) + + suspend fun update(id: Int, new: ApiAnnouncement) = announcementRepository.update(id, new) + + suspend fun delete(id: Int) = announcementRepository.delete(id) + + suspend fun new(new: ApiAnnouncement) = announcementRepository.new(new) + + suspend fun tags() = announcementRepository.tags() } diff --git a/src/main/kotlin/app/revanced/api/configuration/services/ApiService.kt b/src/main/kotlin/app/revanced/api/configuration/services/ApiService.kt index 5dc9f43..8825292 100644 --- a/src/main/kotlin/app/revanced/api/configuration/services/ApiService.kt +++ b/src/main/kotlin/app/revanced/api/configuration/services/ApiService.kt @@ -1,8 +1,9 @@ package app.revanced.api.configuration.services +import app.revanced.api.configuration.* import app.revanced.api.configuration.repository.BackendRepository import app.revanced.api.configuration.repository.ConfigurationRepository -import app.revanced.api.configuration.schema.* +import io.ktor.http.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -16,12 +17,16 @@ internal class ApiService( val about = configurationRepository.about suspend fun contributors() = withContext(Dispatchers.IO) { - configurationRepository.contributorsRepositoryNames.map { + configurationRepository.contributorsRepositoryNames.map { (repository, name) -> async { APIContributable( - it, - backendRepository.contributors(configurationRepository.organization, it).map { - APIContributor(it.name, it.avatarUrl, it.url, it.contributions) + name, + URLBuilder().apply { + 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() suspend fun team() = backendRepository.members(configurationRepository.organization).map { member -> - APIMember( + ApiMember( member.name, member.avatarUrl, member.url, member.bio, 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. member.gpgKeys.ids.first(), member.gpgKeys.url, @@ -47,6 +52,6 @@ internal class ApiService( } suspend fun rateLimit() = backendRepository.rateLimit()?.let { - APIRateLimit(it.limit, it.remaining, it.reset) + ApiRateLimit(it.limit, it.remaining, it.reset) } } diff --git a/src/main/kotlin/app/revanced/api/configuration/services/AuthenticationService.kt b/src/main/kotlin/app/revanced/api/configuration/services/AuthenticationService.kt index f5bbb2b..b691ada 100644 --- a/src/main/kotlin/app/revanced/api/configuration/services/AuthenticationService.kt +++ b/src/main/kotlin/app/revanced/api/configuration/services/AuthenticationService.kt @@ -1,6 +1,6 @@ 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.algorithms.Algorithm import io.ktor.server.auth.* @@ -43,7 +43,7 @@ internal class AuthenticationService private constructor( } } - fun newToken() = APIToken( + fun newToken() = ApiToken( JWT.create() .withIssuer(issuer) .withExpiresAt(Instant.now().plus(validityInMin, ChronoUnit.MINUTES)) diff --git a/src/main/kotlin/app/revanced/api/configuration/services/ManagerService.kt b/src/main/kotlin/app/revanced/api/configuration/services/ManagerService.kt index 17b0665..af53621 100644 --- a/src/main/kotlin/app/revanced/api/configuration/services/ManagerService.kt +++ b/src/main/kotlin/app/revanced/api/configuration/services/ManagerService.kt @@ -1,38 +1,35 @@ package app.revanced.api.configuration.services +import app.revanced.api.configuration.ApiRelease +import app.revanced.api.configuration.ApiReleaseVersion import app.revanced.api.configuration.repository.BackendRepository import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendRelease.Companion.first import app.revanced.api.configuration.repository.ConfigurationRepository -import app.revanced.api.configuration.schema.* internal class ManagerService( private val backendRepository: BackendRepository, private val configurationRepository: ConfigurationRepository, ) { - suspend fun latestRelease(): APIRelease { + suspend fun latestRelease(): ApiRelease { val managerRelease = backendRepository.release( configurationRepository.organization, configurationRepository.manager.repository, ) - val managerAsset = APIManagerAsset( - managerRelease.assets.first(configurationRepository.manager.assetRegex).downloadUrl, - ) - - return APIRelease( + return ApiRelease( managerRelease.tag, managerRelease.createdAt, managerRelease.releaseNote, - listOf(managerAsset), + managerRelease.assets.first(configurationRepository.manager.assetRegex).downloadUrl, ) } - suspend fun latestVersion(): APIReleaseVersion { + suspend fun latestVersion(): ApiReleaseVersion { val managerRelease = backendRepository.release( configurationRepository.organization, configurationRepository.manager.repository, ) - return APIReleaseVersion(managerRelease.tag) + return ApiReleaseVersion(managerRelease.tag) } } diff --git a/src/main/kotlin/app/revanced/api/configuration/services/OldApiService.kt b/src/main/kotlin/app/revanced/api/configuration/services/OldApiService.kt deleted file mode 100644 index 2c464aa..0000000 --- a/src/main/kotlin/app/revanced/api/configuration/services/OldApiService.kt +++ /dev/null @@ -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) - } - }) - } -} diff --git a/src/main/kotlin/app/revanced/api/configuration/services/PatchesService.kt b/src/main/kotlin/app/revanced/api/configuration/services/PatchesService.kt index 6a70041..b9d3608 100644 --- a/src/main/kotlin/app/revanced/api/configuration/services/PatchesService.kt +++ b/src/main/kotlin/app/revanced/api/configuration/services/PatchesService.kt @@ -1,9 +1,11 @@ package app.revanced.api.configuration.services +import app.revanced.api.configuration.ApiAssetPublicKey +import app.revanced.api.configuration.ApiRelease +import app.revanced.api.configuration.ApiReleaseVersion import app.revanced.api.configuration.repository.BackendRepository import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendRelease.Companion.first import app.revanced.api.configuration.repository.ConfigurationRepository -import app.revanced.api.configuration.schema.* import app.revanced.library.serializeTo import app.revanced.patcher.patch.loadPatchesFromJar import com.github.benmanes.caffeine.cache.Caffeine @@ -17,50 +19,28 @@ internal class PatchesService( private val backendRepository: BackendRepository, private val configurationRepository: ConfigurationRepository, ) { - suspend fun latestRelease(): APIRelease { + suspend fun latestRelease(): ApiRelease { val patchesRelease = backendRepository.release( configurationRepository.organization, configurationRepository.patches.repository, ) - val integrationsRelease = backendRepository.release( - configurationRepository.organization, - configurationRepository.integrations.repository, - ) - - fun ConfigurationRepository.SignedAssetConfiguration.asset( - release: BackendRepository.BackendOrganization.BackendRepository.BackendRelease, - assetName: APIAssetName, - ) = APIPatchesAsset( - release.assets.first(assetRegex).downloadUrl, - release.assets.first(signatureAssetRegex).downloadUrl, - assetName, - ) - - val patchesAsset = configurationRepository.patches.asset( - patchesRelease, - APIAssetName.PATCHES, - ) - val integrationsAsset = configurationRepository.integrations.asset( - integrationsRelease, - APIAssetName.INTEGRATION, - ) - - return APIRelease( + return ApiRelease( patchesRelease.tag, patchesRelease.createdAt, 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( configurationRepository.organization, configurationRepository.patches.repository, ) - return APIReleaseVersion(patchesRelease.tag) + return ApiReleaseVersion(patchesRelease.tag) } private val patchesListCache = Caffeine @@ -111,14 +91,5 @@ internal class PatchesService( } } - fun publicKeys(): APIAssetPublicKeys { - fun readPublicKey( - getSignedAssetConfiguration: ConfigurationRepository.() -> ConfigurationRepository.SignedAssetConfiguration, - ) = configurationRepository.getSignedAssetConfiguration().publicKeyFile.readText() - - return APIAssetPublicKeys( - readPublicKey { patches }, - readPublicKey { integrations }, - ) - } + fun publicKey() = ApiAssetPublicKey(configurationRepository.patches.publicKeyFile.readText()) } diff --git a/src/main/kotlin/app/revanced/api/configuration/services/SignatureService.kt b/src/main/kotlin/app/revanced/api/configuration/services/SignatureService.kt index 80eb2d1..fc69005 100644 --- a/src/main/kotlin/app/revanced/api/configuration/services/SignatureService.kt +++ b/src/main/kotlin/app/revanced/api/configuration/services/SignatureService.kt @@ -12,7 +12,7 @@ import java.security.MessageDigest internal class SignatureService { private val signatureCache = Caffeine .newBuilder() - .maximumSize(2) // Assuming this is enough for patches and integrations. + .maximumSize(1) // 1 because currently only the latest patches is needed. .build() // Hash -> Verified. fun verify( diff --git a/src/test/kotlin/app/revanced/api/configuration/services/AnnouncementServiceTest.kt b/src/test/kotlin/app/revanced/api/configuration/services/AnnouncementServiceTest.kt new file mode 100644 index 0000000..b2007a7 --- /dev/null +++ b/src/test/kotlin/app/revanced/api/configuration/services/AnnouncementServiceTest.kt @@ -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") + } +}