Compare commits

..

23 Commits

Author SHA1 Message Date
semantic-release-bot
65ee2700e7 chore: Release v1.4.0-dev.1 [skip ci]
# [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](56a00ddb85))
2024-11-01 01:51:53 +00:00
oSumAtrIX
56a00ddb85 feat: Improve announcements API (#192)
Announcements can have tags now instead of being grouped into a single channel. You can get an announcement using its ID. You can page announcements and filter them by tags and whether they are archived. You can see a list of all available tags. Some route API overhaul.
2024-11-01 02:49:36 +01:00
semantic-release-bot
50b81fd337 chore: Release v1.3.0 [skip ci]
# [1.3.0](https://github.com/ReVanced/revanced-api/compare/v1.2.0...v1.3.0) (2024-10-07)

### Bug Fixes

* Add missing OK response to routes ([1181be1](1181be12e2))
* Allow more necessary HTTP methods for CORS ([080e2e5](080e2e582c))
* Configure CORS properly to allow authorization and content-type header ([6442757](6442757927))
* Expire token relative to current date time instead of just time ([c26e129](c26e129bda))
* Expose www-authenticate header to JS ([9ed724e](9ed724e161))
* Respond with JSON when returning token ([1e3e46f](1e3e46ff4f))
* Specify a validation function to fix authentication ([53c3600](53c36002e9))

### Features

* Add missing parameter and response documentation ([491533d](491533d3f4))
* Customize logging level through environment variable ([8b17d88](8b17d8894d))
* Improve response info description wording ([977d252](977d252497))
* Only allow requests from HTTPs ([a6d7da1](a6d7da1205))
2024-10-07 20:36:41 +00:00
oSumAtrIX
c51db8da72 chore: Merge branch dev to main (#190) 2024-10-07 22:34:27 +02:00
semantic-release-bot
06098415f1 chore: Release v1.3.0-dev.6 [skip ci]
# [1.3.0-dev.6](https://github.com/ReVanced/revanced-api/compare/v1.3.0-dev.5...v1.3.0-dev.6) (2024-10-02)

### Bug Fixes

* Expose www-authenticate header to JS ([9ed724e](9ed724e161))

### Features

* Only allow requests from HTTPs ([a6d7da1](a6d7da1205))
2024-10-02 04:59:16 +00:00
oSumAtrIX
9ed724e161 fix: Expose www-authenticate header to JS 2024-10-02 06:57:20 +02:00
oSumAtrIX
a6d7da1205 feat: Only allow requests from HTTPs 2024-10-02 06:57:19 +02:00
oSumAtrIX
87174eadd6 ci: Use permissions and regular GitHub token instead of PAT 2024-10-02 06:57:19 +02:00
oSumAtrIX
fde2857915 build(Needs bump): Update dependencies 2024-09-30 23:21:46 +02:00
oSumAtrIX
fae8cb6b23 ci: Adjust release commit message 2024-09-30 22:34:25 +02:00
semantic-release-bot
a754159800 chore(release): 1.3.0-dev.5 [skip ci]
# [1.3.0-dev.5](https://github.com/ReVanced/revanced-api/compare/v1.3.0-dev.4...v1.3.0-dev.5) (2024-09-30)

### Bug Fixes

* Allow more necessary HTTP methods for CORS ([080e2e5](080e2e582c))
2024-09-30 15:35:42 +00:00
oSumAtrIX
080e2e582c fix: Allow more necessary HTTP methods for CORS 2024-09-30 17:33:15 +02:00
semantic-release-bot
8ff1bbd41f chore(release): 1.3.0-dev.4 [skip ci]
# [1.3.0-dev.4](https://github.com/ReVanced/revanced-api/compare/v1.3.0-dev.3...v1.3.0-dev.4) (2024-09-29)

### Bug Fixes

* Configure CORS properly to allow authorization and content-type header ([6442757](6442757927))
2024-09-29 23:29:17 +00:00
oSumAtrIX
6442757927 fix: Configure CORS properly to allow authorization and content-type header 2024-09-30 01:27:24 +02:00
semantic-release-bot
710416ff36 chore(release): 1.3.0-dev.3 [skip ci]
# [1.3.0-dev.3](https://github.com/ReVanced/revanced-api/compare/v1.3.0-dev.2...v1.3.0-dev.3) (2024-09-29)

### Bug Fixes

* Add missing OK response to routes ([1181be1](1181be12e2))
* Respond with JSON when returning token ([1e3e46f](1e3e46ff4f))
* Specify a validation function to fix authentication ([53c3600](53c36002e9))

### Features

* Customize logging level through environment variable ([8b17d88](8b17d8894d))
* Improve response info description wording ([977d252](977d252497))
2024-09-29 21:19:18 +00:00
oSumAtrIX
1181be12e2 fix: Add missing OK response to routes 2024-09-29 23:15:35 +02:00
oSumAtrIX
53c36002e9 fix: Specify a validation function to fix authentication 2024-09-29 23:13:13 +02:00
oSumAtrIX
8b17d8894d feat: Customize logging level through environment variable 2024-09-29 01:03:49 +02:00
oSumAtrIX
1e3e46ff4f fix: Respond with JSON when returning token 2024-09-27 20:19:27 +02:00
oSumAtrIX
977d252497 feat: Improve response info description wording 2024-09-27 19:18:32 +02:00
oSumAtrIX
96bcd7719a chore: Remove unnecessary JWT token field 2024-09-27 19:16:34 +02:00
semantic-release-bot
2d85ce17f6 chore(release): 1.3.0-dev.2 [skip ci]
# [1.3.0-dev.2](https://github.com/ReVanced/revanced-api/compare/v1.3.0-dev.1...v1.3.0-dev.2) (2024-09-27)

### Bug Fixes

* Expire token relative to current date time instead of just time ([c26e129](c26e129bda))
2024-09-27 12:23:28 +00:00
oSumAtrIX
c26e129bda fix: Expire token relative to current date time instead of just time 2024-09-27 14:16:45 +02:00
26 changed files with 3243 additions and 1752 deletions

View File

@@ -12,7 +12,7 @@ jobs:
name: Release
runs-on: ubuntu-latest
permissions:
contents: read
contents: write
packages: write
steps:
- name: Checkout
@@ -60,7 +60,7 @@ jobs:
DOCKER_REGISTRY_USER: ${{ github.actor }}
DOCKER_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_TOKEN: ${{ secrets.REPOSITORY_PUSH_ACCESS }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm exec semantic-release
- name: Set Portainer stack webhook URL based on branch

View File

@@ -21,10 +21,10 @@
"@semantic-release/git",
{
"assets": [
"README.md",
"CHANGELOG.md",
"gradle.properties"
]
],
"message": "chore: Release v${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
],
[

View File

@@ -1,3 +1,79 @@
# [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)
### Bug Fixes
* Add missing OK response to routes ([1181be1](https://github.com/ReVanced/revanced-api/commit/1181be12e2223b245019f64570bc8f7bef4e7dc2))
* Allow more necessary HTTP methods for CORS ([080e2e5](https://github.com/ReVanced/revanced-api/commit/080e2e582cb8ea97421c402a4cb82414e11fb1cf))
* Configure CORS properly to allow authorization and content-type header ([6442757](https://github.com/ReVanced/revanced-api/commit/6442757927c0307c01b2793858d25df7e3fca122))
* Expire token relative to current date time instead of just time ([c26e129](https://github.com/ReVanced/revanced-api/commit/c26e129bda09345761f291917f026c13e89a2572))
* Expose www-authenticate header to JS ([9ed724e](https://github.com/ReVanced/revanced-api/commit/9ed724e161f9029967f67e4c2066f2fdf7be0a27))
* Respond with JSON when returning token ([1e3e46f](https://github.com/ReVanced/revanced-api/commit/1e3e46ff4f7c12569b88fcd1bc252aeb5a611b63))
* Specify a validation function to fix authentication ([53c3600](https://github.com/ReVanced/revanced-api/commit/53c36002e9af89aa5fed71f831470b42d5d777c9))
### Features
* Add missing parameter and response documentation ([491533d](https://github.com/ReVanced/revanced-api/commit/491533d3f44ccd716eee80123d0875a05eb9435b))
* Customize logging level through environment variable ([8b17d88](https://github.com/ReVanced/revanced-api/commit/8b17d8894db8db4a168c30be50af91c04e173e14))
* Improve response info description wording ([977d252](https://github.com/ReVanced/revanced-api/commit/977d25249738b24cb6a3530543349efe1d71a9ba))
* Only allow requests from HTTPs ([a6d7da1](https://github.com/ReVanced/revanced-api/commit/a6d7da1205ef7bc23eba0b1fca2480a4327def19))
# [1.3.0-dev.6](https://github.com/ReVanced/revanced-api/compare/v1.3.0-dev.5...v1.3.0-dev.6) (2024-10-02)
### Bug Fixes
* Expose www-authenticate header to JS ([9ed724e](https://github.com/ReVanced/revanced-api/commit/9ed724e161f9029967f67e4c2066f2fdf7be0a27))
### Features
* Only allow requests from HTTPs ([a6d7da1](https://github.com/ReVanced/revanced-api/commit/a6d7da1205ef7bc23eba0b1fca2480a4327def19))
# [1.3.0-dev.5](https://github.com/ReVanced/revanced-api/compare/v1.3.0-dev.4...v1.3.0-dev.5) (2024-09-30)
### Bug Fixes
* Allow more necessary HTTP methods for CORS ([080e2e5](https://github.com/ReVanced/revanced-api/commit/080e2e582cb8ea97421c402a4cb82414e11fb1cf))
# [1.3.0-dev.4](https://github.com/ReVanced/revanced-api/compare/v1.3.0-dev.3...v1.3.0-dev.4) (2024-09-29)
### Bug Fixes
* Configure CORS properly to allow authorization and content-type header ([6442757](https://github.com/ReVanced/revanced-api/commit/6442757927c0307c01b2793858d25df7e3fca122))
# [1.3.0-dev.3](https://github.com/ReVanced/revanced-api/compare/v1.3.0-dev.2...v1.3.0-dev.3) (2024-09-29)
### Bug Fixes
* Add missing OK response to routes ([1181be1](https://github.com/ReVanced/revanced-api/commit/1181be12e2223b245019f64570bc8f7bef4e7dc2))
* Respond with JSON when returning token ([1e3e46f](https://github.com/ReVanced/revanced-api/commit/1e3e46ff4f7c12569b88fcd1bc252aeb5a611b63))
* Specify a validation function to fix authentication ([53c3600](https://github.com/ReVanced/revanced-api/commit/53c36002e9af89aa5fed71f831470b42d5d777c9))
### Features
* Customize logging level through environment variable ([8b17d88](https://github.com/ReVanced/revanced-api/commit/8b17d8894db8db4a168c30be50af91c04e173e14))
* Improve response info description wording ([977d252](https://github.com/ReVanced/revanced-api/commit/977d25249738b24cb6a3530543349efe1d71a9ba))
# [1.3.0-dev.2](https://github.com/ReVanced/revanced-api/compare/v1.3.0-dev.1...v1.3.0-dev.2) (2024-09-27)
### Bug Fixes
* Expire token relative to current date time instead of just time ([c26e129](https://github.com/ReVanced/revanced-api/commit/c26e129bda09345761f291917f026c13e89a2572))
# [1.3.0-dev.1](https://github.com/ReVanced/revanced-api/compare/v1.2.0...v1.3.0-dev.1) (2024-09-11)

View File

@@ -75,7 +75,7 @@ with updates and ReVanced Patches.
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,
and links of the hoster of ReVanced API
- 🧩 **Patches**: Get the latest updates of ReVanced Patches, directly from ReVanced API

View File

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

View File

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

3648
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"@saithodev/semantic-release-backmerge": "^4.0.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"gradle-semantic-release-plugin": "^1.9.2",
"semantic-release": "^24.0.0"
"gradle-semantic-release-plugin": "^1.10.1",
"semantic-release": "^24.1.2"
}
}

View File

@@ -1,6 +1,7 @@
package app.revanced.api.command
import app.revanced.api.configuration.*
import io.github.cdimascio.dotenv.Dotenv
import io.ktor.server.engine.*
import io.ktor.server.jetty.*
import picocli.CommandLine
@@ -33,6 +34,8 @@ internal object StartAPICommand : Runnable {
private var configFile = File("configuration.toml")
override fun run() {
Dotenv.configure().systemProperties().load()
embeddedServer(Jetty, port, host) {
configureDependencies(configFile)
configureHTTP()

View File

@@ -7,12 +7,11 @@ import app.revanced.api.configuration.repository.GitHubBackendRepository
import app.revanced.api.configuration.services.*
import app.revanced.api.configuration.services.AnnouncementService
import app.revanced.api.configuration.services.ApiService
import app.revanced.api.configuration.services.AuthService
import app.revanced.api.configuration.services.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.github.cdimascio.dotenv.Dotenv
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.*
@@ -38,11 +37,7 @@ import java.io.File
fun Application.configureDependencies(
configFile: File,
) {
val globalModule = module {
single {
Dotenv.configure().load()
}
val miscModule = module {
factory { params ->
val defaultRequestUri: String = params.get<String>()
val configBlock = params.getOrNull<(HttpClientConfig<OkHttpConfig>.() -> Unit)>() ?: {}
@@ -72,7 +67,7 @@ fun Application.configureDependencies(
)
}
get<Dotenv>()["BACKEND_API_TOKEN"]?.let {
System.getProperty("BACKEND_API_TOKEN")?.let {
install(Auth) {
bearer {
loadTokens {
@@ -98,12 +93,10 @@ fun Application.configureDependencies(
}
single {
val dotenv = get<Dotenv>()
TransactionManager.defaultDatabase = Database.connect(
url = dotenv["DB_URL"],
user = dotenv["DB_USER"],
password = dotenv["DB_PASSWORD"],
url = System.getProperty("DB_URL"),
user = System.getProperty("DB_USER"),
password = System.getProperty("DB_PASSWORD"),
)
AnnouncementRepository()
@@ -112,15 +105,13 @@ fun Application.configureDependencies(
val serviceModule = module {
single {
val dotenv = get<Dotenv>()
val jwtSecret = System.getProperty("JWT_SECRET")
val issuer = System.getProperty("JWT_ISSUER")
val validityInMin = System.getProperty("JWT_VALIDITY_IN_MIN").toLong()
val jwtSecret = dotenv["JWT_SECRET"]
val issuer = dotenv["JWT_ISSUER"]
val validityInMin = dotenv["JWT_VALIDITY_IN_MIN"].toInt()
val authSHA256DigestString = System.getProperty("AUTH_SHA256_DIGEST")
val authSHA256DigestString = dotenv["AUTH_SHA256_DIGEST"]
AuthService(issuer, validityInMin, jwtSecret, authSHA256DigestString)
AuthenticationService(issuer, validityInMin, jwtSecret, authSHA256DigestString)
}
single {
val configuration = get<ConfigurationRepository>()
@@ -140,7 +131,7 @@ fun Application.configureDependencies(
install(Koin) {
modules(
globalModule,
miscModule,
repositoryModule,
serviceModule,
)

View File

@@ -1,6 +1,7 @@
package app.revanced.api.configuration
import app.revanced.api.configuration.repository.ConfigurationRepository
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.*
import io.ktor.server.plugins.cors.routing.*
@@ -13,11 +14,16 @@ fun Application.configureHTTP() {
val configurationRepository = get<ConfigurationRepository>()
install(CORS) {
HttpMethod.DefaultMethods.minus(HttpMethod.Options).forEach(::allowMethod)
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Authorization)
exposeHeader(HttpHeaders.WWWAuthenticate)
allowCredentials = true
configurationRepository.corsAllowedHosts.forEach { host ->
allowHost(
host = host,
schemes = listOf("http", "https")
)
allowHost(host = host, schemes = listOf("https"))
}
}

View File

@@ -1,9 +1,17 @@
package app.revanced.api.configuration
import app.revanced.api.configuration.services.AuthService
import app.revanced.api.configuration.services.AuthenticationService
import io.ktor.server.application.*
import io.ktor.server.auth.*
import org.koin.ktor.ext.get
fun Application.configureSecurity() {
get<AuthService>().configureSecurity(this)
val authenticationService = get<AuthenticationService>()
install(Authentication) {
with(authenticationService) {
jwt()
digest()
}
}
}

View File

@@ -1,10 +1,10 @@
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.schema.ApiAnnouncement
import app.revanced.api.configuration.schema.ApiAnnouncementTag
import app.revanced.api.configuration.schema.ApiResponseAnnouncement
import app.revanced.api.configuration.schema.ApiResponseAnnouncementId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.*
import org.jetbrains.exposed.dao.IntEntity
@@ -15,126 +15,175 @@ 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
import java.time.LocalDateTime
internal class AnnouncementRepository {
// 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 val latestAnnouncementByChannel = mutableMapOf<String, Announcement>()
private fun updateLatestAnnouncement(new: Announcement) {
if (latestAnnouncement?.id?.value == new.id.value) {
latestAnnouncement = new
latestAnnouncementByChannel[new.channel ?: return] = new
}
}
private val latestAnnouncementByTag = mutableMapOf<Int, Announcement>()
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.id.value }.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.id.value] = new }
}
}
private fun updateLatestAnnouncementForTag(tag: Int) {
val latestAnnouncementForTag = AnnouncementTags.select(AnnouncementTags.announcement)
.where { AnnouncementTags.tag eq tag }
.map { it[AnnouncementTags.announcement] }
.mapNotNull { Announcement.findById(it) }
.maxByOrNull { it.id }
latestAnnouncementForTag?.let { latestAnnouncementByTag[tag] = it }
}
suspend fun latest() = transaction {
latestAnnouncement.toApiResponseAnnouncement()
}
suspend fun latest(tags: Set<Int>) = transaction {
tags.mapNotNull { tag -> latestAnnouncementByTag[tag] }.toApiAnnouncement()
}
fun latestId() = latestAnnouncement?.id?.value.toApiResponseAnnouncementId()
fun latestId(tags: Set<Int>) =
tags.map { tag -> latestAnnouncementByTag[tag]?.id?.value }.toApiResponseAnnouncementId()
suspend fun paged(cursor: Int, count: Int, tags: Set<Int>?, archived: Boolean) = transaction {
Announcement.find {
fun idLessEq() = Announcements.id lessEq cursor
fun archivedAtIsNull() = Announcements.archivedAt.isNull()
fun archivedAtGreaterNow() = Announcements.archivedAt greater LocalDateTime.now().toKotlinLocalDateTime()
if (tags == null) {
if (archived) {
idLessEq()
} else {
idLessEq() and (archivedAtIsNull() or archivedAtGreaterNow())
}
} else {
fun archivedAtGreaterOrNullOrTrue() = if (archived) {
Op.TRUE
} else {
archivedAtIsNull() or archivedAtGreaterNow()
}
fun hasTags() = tags.mapNotNull { Tag.findById(it)?.id }.let { tags ->
Announcements.id inSubQuery Announcements.leftJoin(AnnouncementTags)
.select(AnnouncementTags.announcement)
.where { AnnouncementTags.tag inList tags }
.withDistinct()
}
idLessEq() and archivedAtGreaterOrNullOrTrue() and hasTags()
}
}.orderBy(Announcements.id to SortOrder.DESC).limit(count).toApiAnnouncement()
}
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.id.value
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 <T> transaction(statement: suspend Transaction.() -> T) =
@@ -144,7 +193,6 @@ internal class AnnouncementRepository {
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 +203,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<Int>) : IntEntity(id) {
companion object : IntEntityClass<Announcement>(Announcements)
@@ -162,7 +223,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 +236,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<Int>) : IntEntity(id) {
companion object : IntEntityClass<Tag>(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.id.value },
createdAt,
archivedAt,
level,
)
}
private fun Iterable<Announcement>.toApiAnnouncement() = map { it.toApiResponseAnnouncement()!! }
private fun Iterable<Tag>.toApiTag() = map { ApiAnnouncementTag(it.id.value, it.name) }
private fun Int?.toApiResponseAnnouncementId() = this?.let { ApiResponseAnnouncementId(this) }
private fun Iterable<Int?>.toApiResponseAnnouncementId() = map { it.toApiResponseAnnouncementId() }
}

View File

@@ -4,10 +4,9 @@ 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.schema.ApiAnnouncement
import app.revanced.api.configuration.schema.ApiResponseAnnouncement
import app.revanced.api.configuration.schema.ApiResponseAnnouncementId
import app.revanced.api.configuration.services.AnnouncementService
import io.bkbn.kompendium.core.metadata.*
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
@@ -16,7 +15,6 @@ 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,105 +30,85 @@ 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")
val archived = call.parameters["archived"]?.toBoolean() ?: true
call.respond(announcementService.paged(cursor, count, tags?.map { it.toInt() }?.toSet(), archived))
}
}
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<APIAnnouncement> { announcement ->
post<ApiAnnouncement> { announcement ->
announcementService.new(announcement)
call.respond(HttpStatusCode.OK)
}
}
route("latest") {
installAnnouncementsLatestRouteDocumentation()
get {
val tags = call.parameters.getAll("tag")
if (tags?.isNotEmpty() == true) {
call.respond(announcementService.latest(tags.map { it.toInt() }.toSet()))
} else {
call.respondOrNotFound(announcementService.latest())
}
}
route("{id}") {
installAnnouncementIdRouteDocumentation()
route("id") {
installAnnouncementsLatestIdRouteDocumentation()
patch<APIAnnouncement> { announcement ->
get {
val tags = call.parameters.getAll("tag")
if (tags?.isNotEmpty() == true) {
call.respond(announcementService.latestId(tags.map { it.toInt() }.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
announcementService.update(id, announcement)
call.respond(HttpStatusCode.OK)
}
delete {
val id: Int by call.parameters
announcementService.delete(id)
call.respond(HttpStatusCode.OK)
}
}
}
route("archive") {
installAnnouncementArchiveRouteDocumentation()
route("tags") {
installAnnouncementsTagsRouteDocumentation()
post {
val id: Int by call.parameters
val archivedAt = call.receiveNullable<APIAnnouncementArchivedAt>()?.archivedAt
announcementService.archive(id, archivedAt)
}
}
route("unarchive") {
installAnnouncementUnarchiveRouteDocumentation()
post {
val id: Int by call.parameters
announcementService.unarchive(id)
}
}
get {
call.respond(announcementService.tags())
}
}
}
@@ -144,20 +122,60 @@ 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.INT,
description = "The tag IDs to filter the announcements by. Default is all tags",
required = false,
),
Parameter(
name = "archived",
`in` = Parameter.Location.query,
schema = TypeDefinition.BOOLEAN,
description = "Whether to include archived announcements. Default is true",
required = false,
),
)
response {
responseCode(HttpStatusCode.OK)
mediaTypes("application/json")
description("The announcements")
responseType<Set<ApiResponseAnnouncement>>()
}
}
post = PostInfo.builder {
description("Create a new announcement")
summary("Create announcement")
parameters(authHeaderParameter)
request {
requestType<APIAnnouncement>()
requestType<ApiAnnouncement>()
description("The new announcement")
}
response {
description("When the announcement was created")
description("The announcement is created")
responseCode(HttpStatusCode.OK)
responseType<Unit>()
}
@@ -165,17 +183,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.INT,
description = "The tag IDs to filter the latest announcements by",
required = false,
),
)
response {
responseCode(HttpStatusCode.OK)
mediaTypes("application/json")
description("The latest announcement")
responseType<APIResponseAnnouncement>()
responseType<ApiResponseAnnouncement>()
}
canRespond {
responseCode(HttpStatusCode.OK)
mediaTypes("application/json")
description("The latest announcements")
responseType<Set<ApiResponseAnnouncement>>()
}
canRespond {
responseCode(HttpStatusCode.NotFound)
@@ -185,17 +218,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.INT,
description = "The tag IDs to filter the latest announcements by",
required = false,
),
)
response {
responseCode(HttpStatusCode.OK)
mediaTypes("application/json")
description("The id of the latest announcement")
responseType<APIResponseAnnouncementId>()
description("The ID of the latest announcement")
responseType<ApiResponseAnnouncementId>()
}
canRespond {
responseCode(HttpStatusCode.OK)
mediaTypes("application/json")
description("The IDs of the latest announcements")
responseType<Set<ApiResponseAnnouncement>>()
}
canRespond {
responseCode(HttpStatusCode.NotFound)
@@ -205,113 +253,44 @@ 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<Set<APIResponseAnnouncement>>()
responseType<ApiResponseAnnouncement>()
}
}
}
private fun Route.installAnnouncementArchiveRouteDocumentation() = installNotarizedRoute {
tags = setOf("Announcements")
parameters = listOf(
Parameter(
name = "id",
`in` = Parameter.Location.path,
schema = TypeDefinition.INT,
description = "The id of the announcement to archive",
required = true,
),
Parameter(
name = "archivedAt",
`in` = Parameter.Location.query,
schema = TypeDefinition.STRING,
description = "The date and time the announcement to be archived",
required = false,
),
authHeaderParameter,
)
post = PostInfo.builder {
description("Archive an announcement")
summary("Archive announcement")
response {
description("When the announcement was archived")
responseCode(HttpStatusCode.OK)
canRespond {
responseCode(HttpStatusCode.NotFound)
description("The announcement does not exist")
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("When announcement was 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 {
description("Update an announcement")
summary("Update announcement")
request {
requestType<APIAnnouncement>()
requestType<ApiAnnouncement>()
description("The new announcement")
}
response {
description("When announcement was updated")
description("The announcement is updated")
responseCode(HttpStatusCode.OK)
responseType<Unit>()
}
@@ -322,7 +301,7 @@ private fun Route.installAnnouncementIdRouteDocumentation() = installNotarizedRo
description("Delete an announcement")
summary("Delete announcement")
response {
description("When the announcement was deleted")
description("The announcement is deleted")
responseCode(HttpStatusCode.OK)
responseType<Unit>()
}
@@ -330,77 +309,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<Set<APIResponseAnnouncement>>()
}
}
}
private fun Route.installLatestChannelAnnouncementRouteDocumentation() = installNotarizedRoute {
tags = setOf("Announcements")
parameters = listOf(
Parameter(
name = "channel",
`in` = Parameter.Location.path,
schema = TypeDefinition.STRING,
description = "The channel to get the latest announcement from",
required = true,
),
)
get = GetInfo.builder {
description("Get the latest announcement from a channel")
summary("Get latest channel announcement")
response {
responseCode(HttpStatusCode.OK)
mediaTypes("application/json")
description("The latest announcement in the channel")
responseType<APIResponseAnnouncement>()
}
canRespond {
responseCode(HttpStatusCode.NotFound)
description("The channel does not exist")
responseType<Unit>()
}
}
}
private fun Route.installLatestChannelAnnouncementIdRouteDocumentation() = installNotarizedRoute {
tags = setOf("Announcements")
parameters = listOf(
Parameter(
name = "channel",
`in` = Parameter.Location.path,
schema = TypeDefinition.STRING,
description = "The channel to get the latest announcement id from",
required = true,
),
)
get = GetInfo.builder {
description("Get the id of the latest announcement from a channel")
summary("Get id of latest announcement from channel")
response {
responseCode(HttpStatusCode.OK)
mediaTypes("application/json")
description("The id of the latest announcement from the channel")
responseType<APIResponseAnnouncementId>()
}
canRespond {
responseCode(HttpStatusCode.NotFound)
description("The channel does not exist")
responseType<Unit>()
description("The announcement tags")
responseType<Set<String>>()
}
}
}

View File

@@ -6,12 +6,9 @@ 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.APIAbout
import app.revanced.api.configuration.schema.APIContributable
import app.revanced.api.configuration.schema.APIMember
import app.revanced.api.configuration.schema.APIRateLimit
import app.revanced.api.configuration.schema.*
import app.revanced.api.configuration.services.ApiService
import app.revanced.api.configuration.services.AuthService
import app.revanced.api.configuration.services.AuthenticationService
import io.bkbn.kompendium.core.metadata.*
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.payload.Parameter
@@ -21,13 +18,12 @@ import io.ktor.server.auth.*
import io.ktor.server.plugins.ratelimit.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.json.Json.Default.configuration
import kotlin.time.Duration.Companion.days
import org.koin.ktor.ext.get as koinGet
internal fun Route.apiRoute() {
val apiService = koinGet<ApiService>()
val authService = koinGet<AuthService>()
val authenticationService = koinGet<AuthenticationService>()
rateLimit(RateLimitName("strong")) {
authenticate("auth-digest") {
@@ -35,7 +31,7 @@ internal fun Route.apiRoute() {
installTokenRouteDocumentation()
get {
call.respond(authService.newToken())
call.respond(authenticationService.newToken())
}
}
}
@@ -119,7 +115,7 @@ private fun Route.installRateLimitRouteDocumentation() = installNotarizedRoute {
description("The rate limit of the backend")
mediaTypes("application/json")
responseCode(HttpStatusCode.OK)
responseType<APIRateLimit>()
responseType<ApiRateLimit>()
}
}
}
@@ -148,7 +144,7 @@ private fun Route.installTeamRouteDocumentation() = installNotarizedRoute {
description("The list of team members")
mediaTypes("application/json")
responseCode(HttpStatusCode.OK)
responseType<Set<APIMember>>()
responseType<Set<ApiMember>>()
}
}
}
@@ -199,7 +195,7 @@ private fun Route.installTokenRouteDocumentation() = installNotarizedRoute {
description("The authorization token")
mediaTypes("application/json")
responseCode(HttpStatusCode.OK)
responseType<String>()
responseType<ApiToken>()
}
canRespondUnauthorized()
}

View File

@@ -1,9 +1,9 @@
package app.revanced.api.configuration.routes
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.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.*
@@ -53,7 +53,7 @@ private fun Route.installManagerRouteDocumentation(deprecated: Boolean) = instal
description("The latest manager release")
mediaTypes("application/json")
responseCode(HttpStatusCode.OK)
responseType<APIRelease<APIManagerAsset>>()
responseType<ApiRelease<ApiManagerAsset>>()
}
}
}
@@ -69,7 +69,7 @@ private fun Route.installManagerVersionRouteDocumentation(deprecated: Boolean) =
description("The current manager release version")
mediaTypes("application/json")
responseCode(HttpStatusCode.OK)
responseType<APIReleaseVersion>()
responseType<ApiReleaseVersion>()
}
}
}

View File

@@ -2,10 +2,10 @@ package app.revanced.api.configuration.routes
import app.revanced.api.configuration.installCache
import app.revanced.api.configuration.installNotarizedRoute
import app.revanced.api.configuration.schema.APIAssetPublicKeys
import app.revanced.api.configuration.schema.APIPatchesAsset
import app.revanced.api.configuration.schema.APIRelease
import app.revanced.api.configuration.schema.APIReleaseVersion
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.*
@@ -78,7 +78,7 @@ private fun Route.installPatchesRouteDocumentation(deprecated: Boolean) = instal
description("The current patches release")
mediaTypes("application/json")
responseCode(HttpStatusCode.OK)
responseType<APIRelease<APIPatchesAsset>>()
responseType<ApiRelease<ApiPatchesAsset>>()
}
}
}
@@ -94,7 +94,7 @@ private fun Route.installPatchesVersionRouteDocumentation(deprecated: Boolean) =
description("The current patches release version")
mediaTypes("application/json")
responseCode(HttpStatusCode.OK)
responseType<APIReleaseVersion>()
responseType<ApiReleaseVersion>()
}
}
}
@@ -126,7 +126,7 @@ private fun Route.installPatchesPublicKeyRouteDocumentation(deprecated: Boolean)
description("The public keys")
mediaTypes("application/json")
responseCode(HttpStatusCode.OK)
responseType<APIAssetPublicKeys>()
responseType<ApiAssetPublicKeys>()
}
}
}

View File

@@ -3,44 +3,44 @@ package app.revanced.api.configuration.schema
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,
// Using a list instead of a set because set semantics are unnecessary here.
val contributors: List<APIContributor>,
val contributors: List<ApiContributor>,
)
@Serializable
class APIRelease<T>(
class ApiRelease<T>(
val version: String,
val createdAt: LocalDateTime,
val description: String,
@@ -49,74 +49,82 @@ class APIRelease<T>(
)
@Serializable
class APIManagerAsset(
class ApiManagerAsset(
val downloadUrl: String,
)
@Serializable
class APIPatchesAsset(
class ApiPatchesAsset(
val downloadUrl: String,
val signatureDownloadUrl: String,
// TODO: Remove this eventually when integrations are merged into patches.
val name: APIAssetName,
val name: ApiAssetName,
)
@Serializable
enum class APIAssetName {
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<String> = emptyList(),
val channel: String? = null,
val attachments: List<String> = emptyList(),
// Using a list instead of a set because set semantics are unnecessary here.
val tags: List<String> = 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<String> = emptyList(),
val channel: String? = null,
val attachments: List<String> = emptyList(),
// Using a list instead of a set because set semantics are unnecessary here.
val tags: List<Int> = 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 id: Int,
val name: String,
)
@Serializable
class ApiRateLimit(
val limit: Int,
val remaining: Int,
val reset: LocalDateTime,
)
@Serializable
class APIAssetPublicKeys(
class ApiAssetPublicKeys(
val patchesPublicKey: String,
val integrationsPublicKey: String,
)
@@ -172,3 +180,6 @@ class APIAbout(
val links: List<Link>?,
)
}
@Serializable
class ApiToken(val token: String)

View File

@@ -1,35 +1,29 @@
package app.revanced.api.configuration.services
import app.revanced.api.configuration.repository.AnnouncementRepository
import app.revanced.api.configuration.schema.APIAnnouncement
import app.revanced.api.configuration.schema.APIResponseAnnouncementId
import kotlinx.datetime.LocalDateTime
import app.revanced.api.configuration.schema.ApiAnnouncement
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<Int>) = 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<Int>) = 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<Int>?, archived: Boolean) =
announcementRepository.paged(cursor, limit, tags, archived)
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()
}

View File

@@ -21,7 +21,7 @@ internal class ApiService(
APIContributable(
it,
backendRepository.contributors(configurationRepository.organization, it).map {
APIContributor(it.name, it.avatarUrl, it.url, it.contributions)
ApiContributor(it.name, it.avatarUrl, it.url, it.contributions)
},
)
}
@@ -29,13 +29,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 +47,6 @@ internal class ApiService(
}
suspend fun rateLimit() = backendRepository.rateLimit()?.let {
APIRateLimit(it.limit, it.remaining, it.reset)
ApiRateLimit(it.limit, it.remaining, it.reset)
}
}

View File

@@ -1,49 +0,0 @@
package app.revanced.api.configuration.services
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import java.util.*
import kotlin.text.HexFormat
import kotlin.time.Duration.Companion.minutes
internal class AuthService private constructor(
private val issuer: String,
private val validityInMin: Int,
private val jwtSecret: String,
private val authSHA256Digest: ByteArray,
) {
@OptIn(ExperimentalStdlibApi::class)
constructor(issuer: String, validityInMin: Int, jwtSecret: String, authSHA256DigestString: String) : this(
issuer,
validityInMin,
jwtSecret,
authSHA256DigestString.hexToByteArray(HexFormat.Default),
)
val configureSecurity: Application.() -> Unit = {
install(Authentication) {
jwt("jwt") {
realm = "ReVanced"
verifier(JWT.require(Algorithm.HMAC256(jwtSecret)).withIssuer(issuer).build())
}
digest("auth-digest") {
realm = "ReVanced"
algorithmName = "SHA-256"
digestProvider { _, _ ->
authSHA256Digest
}
}
}
}
fun newToken(): String = JWT.create()
.withIssuer(issuer)
.withExpiresAt(Date(System.currentTimeMillis() + validityInMin.minutes.inWholeMilliseconds))
.sign(Algorithm.HMAC256(jwtSecret))
}

View File

@@ -0,0 +1,52 @@
package app.revanced.api.configuration.services
import app.revanced.api.configuration.schema.ApiToken
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import java.time.Instant
import java.time.temporal.ChronoUnit
import kotlin.text.HexFormat
internal class AuthenticationService private constructor(
private val issuer: String,
private val validityInMin: Long,
private val jwtSecret: String,
private val authSHA256Digest: ByteArray,
) {
@OptIn(ExperimentalStdlibApi::class)
constructor(issuer: String, validityInMin: Long, jwtSecret: String, authSHA256DigestString: String) : this(
issuer,
validityInMin,
jwtSecret,
authSHA256DigestString.hexToByteArray(HexFormat.Default),
)
fun AuthenticationConfig.jwt() {
jwt("jwt") {
realm = "ReVanced"
verifier(JWT.require(Algorithm.HMAC256(jwtSecret)).withIssuer(issuer).build())
// This is required and not optional. Authentication will fail if this is not present.
validate { JWTPrincipal(it.payload) }
}
}
fun AuthenticationConfig.digest() {
digest("auth-digest") {
realm = "ReVanced"
algorithmName = "SHA-256"
digestProvider { _, _ ->
authSHA256Digest
}
}
}
fun newToken() = ApiToken(
JWT.create()
.withIssuer(issuer)
.withExpiresAt(Instant.now().plus(validityInMin, ChronoUnit.MINUTES))
.sign(Algorithm.HMAC256(jwtSecret)),
)
}

View File

@@ -9,17 +9,17 @@ internal class ManagerService(
private val backendRepository: BackendRepository,
private val configurationRepository: ConfigurationRepository,
) {
suspend fun latestRelease(): APIRelease<APIManagerAsset> {
suspend fun latestRelease(): ApiRelease<ApiManagerAsset> {
val managerRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.manager.repository,
)
val managerAsset = APIManagerAsset(
val managerAsset = ApiManagerAsset(
managerRelease.assets.first(configurationRepository.manager.assetRegex).downloadUrl,
)
return APIRelease(
return ApiRelease(
managerRelease.tag,
managerRelease.createdAt,
managerRelease.releaseNote,
@@ -27,12 +27,12 @@ internal class ManagerService(
)
}
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)
}
}

View File

@@ -17,7 +17,7 @@ internal class PatchesService(
private val backendRepository: BackendRepository,
private val configurationRepository: ConfigurationRepository,
) {
suspend fun latestRelease(): APIRelease<APIPatchesAsset> {
suspend fun latestRelease(): ApiRelease<ApiPatchesAsset> {
val patchesRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.patches.repository,
@@ -30,8 +30,8 @@ internal class PatchesService(
fun ConfigurationRepository.SignedAssetConfiguration.asset(
release: BackendRepository.BackendOrganization.BackendRepository.BackendRelease,
assetName: APIAssetName,
) = APIPatchesAsset(
assetName: ApiAssetName,
) = ApiPatchesAsset(
release.assets.first(assetRegex).downloadUrl,
release.assets.first(signatureAssetRegex).downloadUrl,
assetName,
@@ -39,14 +39,14 @@ internal class PatchesService(
val patchesAsset = configurationRepository.patches.asset(
patchesRelease,
APIAssetName.PATCHES,
ApiAssetName.PATCHES,
)
val integrationsAsset = configurationRepository.integrations.asset(
integrationsRelease,
APIAssetName.INTEGRATION,
ApiAssetName.INTEGRATION,
)
return APIRelease(
return ApiRelease(
patchesRelease.tag,
patchesRelease.createdAt,
patchesRelease.releaseNote,
@@ -54,13 +54,13 @@ internal class PatchesService(
)
}
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,12 +111,12 @@ internal class PatchesService(
}
}
fun publicKeys(): APIAssetPublicKeys {
fun publicKeys(): ApiAssetPublicKeys {
fun readPublicKey(
getSignedAssetConfiguration: ConfigurationRepository.() -> ConfigurationRepository.SignedAssetConfiguration,
) = configurationRepository.getSignedAssetConfiguration().publicKeyFile.readText()
return APIAssetPublicKeys(
return ApiAssetPublicKeys(
readPublicKey { patches },
readPublicKey { integrations },
)

View File

@@ -4,7 +4,7 @@
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="info">
<root level="\${LOG_LEVEL:-INFO}">
<appender-ref ref="STDOUT"/>
</root>
</configuration>

View File

@@ -0,0 +1,194 @@
package app.revanced.api.configuration.services
import app.revanced.api.configuration.repository.AnnouncementRepository
import app.revanced.api.configuration.schema.ApiAnnouncement
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.toKotlinLocalDateTime
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.assertNull
import java.time.LocalDateTime
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
private object AnnouncementServiceTest {
private lateinit var announcementService: AnnouncementService
@JvmStatic
@BeforeAll
fun setUp() {
TransactionManager.defaultDatabase =
Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false")
announcementService = AnnouncementService(AnnouncementRepository())
}
@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")))
val tag2 = announcementService.tags().find { it.name == "tag2" }!!.id
assert(announcementService.latest(setOf(tag2)).first().title == "1")
val tag3 = announcementService.tags().find { it.name == "tag3" }!!.id
assert(announcementService.latest(setOf(tag3)).last().title == "2")
val tag1and3 =
announcementService.tags().filter { it.name == "tag1" || it.name == "tag3" }.map { it.id }.toSet()
val announcement2and3 = announcementService.latest(tag1and3)
assert(announcement2and3.size == 2)
assert(announcement2and3.any { it.title == "2" })
assert(announcement2and3.any { it.title == "3" })
announcementService.delete(announcementService.latestId()!!.id)
assert(announcementService.latest(tag1and3).first().title == "2")
announcementService.delete(announcementService.latestId()!!.id)
assert(announcementService.latest(tag1and3).first().title == "1")
announcementService.delete(announcementService.latestId()!!.id)
assert(announcementService.latest(tag1and3).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, true)
assertEquals(5, announcements.size, "Returns correct number of announcements")
assertEquals("title9", announcements.first().title, "Starts from the latest announcement")
val announcements2 = announcementService.paged(5, 5, null, true)
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].id), true)
assertEquals(4, announcements3.size, "Filters announcements by tag")
val announcements4 = announcementService.paged(Int.MAX_VALUE, 10, null, false)
assertEquals(8, announcements4.size, "Filters out archived announcements")
}
}