refactor: Refactor into services and repositories

This commit is contained in:
oSumAtrIX 2024-06-05 03:07:28 +02:00
parent 7a1957d013
commit fa2f8b2f86
No known key found for this signature in database
GPG Key ID: A9B3094ACDB604B4
30 changed files with 623 additions and 542 deletions

View File

@ -11,21 +11,11 @@ tasks {
expand("projectVersion" to project.version)
}
/*
Dummy task to hack gradle-semantic-release-plugin to release this project.
Explanation:
SemVer is a standard for versioning libraries.
For that reason the semantic-release plugin uses the "publish" task to publish libraries.
However, this subproject is not a library, and the "publish" task is not available for this subproject.
Because semantic-release is not designed to handle this case, we need to hack it.
RE: https://github.com/KengoTODA/gradle-semantic-release-plugin/issues/435
*/
// Needed by gradle-semantic-release-plugin.
// Tracking: https://github.com/KengoTODA/gradle-semantic-release-plugin/issues/435
register<DefaultTask>("publish") {
group = "publishing"
description = "Dummy task to hack gradle-semantic-release-plugin to release ReVanced API"
dependsOn(startShadowScripts)
dependsOn(shadowJar)
}
}
@ -42,8 +32,15 @@ ktor {
repositories {
mavenCentral()
google()
maven { url = uri("https://jitpack.io") }
mavenLocal()
maven {
// A repository must be specified for some reason. "registry" is a dummy.
url = uri("https://maven.pkg.github.com/revanced/registry")
credentials {
username = project.findProperty("gpr.user") as String? ?: System.getenv("GITHUB_ACTOR")
password = project.findProperty("gpr.key") as String? ?: System.getenv("GITHUB_TOKEN")
}
}
}
dependencies {
@ -78,8 +75,4 @@ dependencies {
implementation(libs.revanced.patcher)
implementation(libs.revanced.library)
implementation(libs.caffeine)
testImplementation(libs.mockk)
testImplementation(libs.ktor.server.tests)
testImplementation(libs.kotlin.test.junit)
}

View File

@ -1,5 +1,5 @@
[versions]
kotlin = "1.9.22"
kotlin = "2.0.0"
logback = "1.4.14"
exposed = "0.41.1"
h2 = "2.2.224"
@ -7,11 +7,10 @@ koin = "3.5.3"
dotenv = "6.4.1"
ktor = "2.3.7"
ktoml = "0.5.1"
picocli = "4.7.3"
picocli = "4.7.5"
datetime = "0.5.0"
mockk = "1.13.9"
revanced-patcher = "19.2.0"
revanced-library = "1.5.0"
revanced-patcher = "19.3.1"
revanced-library = "2.3.0"
caffeine = "3.1.8"
[libraries]
@ -39,13 +38,10 @@ exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "e
exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" }
exposed-kotlin-datetime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" }
dotenv-kotlin = { module = "io.github.cdimascio:dotenv-kotlin", version.ref = "dotenv" }
ktor-server-tests = { module = "io.ktor:ktor-server-tests" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
ktoml-core = { module = "com.akuleshov7:ktoml-core", version.ref = "ktoml" }
ktoml-file = { module = "com.akuleshov7:ktoml-file", version.ref = "ktoml" }
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
revanced-patcher = { module = "app.revanced:revanced-patcher", version.ref = "revanced-patcher" }
revanced-library = { module = "app.revanced:revanced-library", version.ref = "revanced-library" }
caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" }

Binary file not shown.

View File

@ -1,5 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
distributionSha256Sum=a4b4158601f8636cdeeab09bd76afb640030bb5b144aafe261a5e8af027dc612
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

41
gradlew vendored
View File

@ -55,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@ -80,13 +80,11 @@ do
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@ -133,22 +131,29 @@ location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@ -193,11 +198,15 @@ if "$cygwin" || "$msys" ; then
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
@ -205,6 +214,12 @@ set -- \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.

35
gradlew.bat vendored
View File

@ -14,7 +14,7 @@
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@ -25,7 +25,8 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@ -40,13 +41,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
@ -56,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal

View File

@ -1,6 +1,7 @@
package app.revanced.api.command
import app.revanced.api.modules.*
import app.revanced.api.configuration.*
import app.revanced.api.configuration.routing.configureRouting
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import picocli.CommandLine
@ -27,7 +28,7 @@ internal object StartAPICommand : Runnable {
override fun run() {
embeddedServer(Netty, port, host) {
configureDependencies()
configureHTTP()
configureHTTP(allowedHost = host)
configureSerialization()
configureSecurity()
configureRouting()

View File

@ -0,0 +1,83 @@
package app.revanced.api.configuration
import app.revanced.api.repository.AnnouncementRepository
import app.revanced.api.repository.ConfigurationRepository
import app.revanced.api.repository.backend.BackendRepository
import app.revanced.api.repository.backend.github.GitHubBackendRepository
import app.revanced.api.services.AnnouncementService
import app.revanced.api.services.ApiService
import app.revanced.api.services.AuthService
import app.revanced.api.services.PatchesService
import com.akuleshov7.ktoml.Toml
import com.akuleshov7.ktoml.source.decodeFromStream
import io.github.cdimascio.dotenv.Dotenv
import io.ktor.server.application.*
import org.jetbrains.exposed.sql.Database
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
import org.koin.ktor.plugin.Koin
import java.io.File
fun Application.configureDependencies() {
val globalModule = module {
single {
Dotenv.configure()
.systemProperties()
.load()
}
}
val repositoryModule = module {
single {
val dotenv = get<Dotenv>()
Database.connect(
url = dotenv["DB_URL"],
user = dotenv["DB_USER"],
password = dotenv["DB_PASSWORD"],
driver = "org.h2.Driver",
)
}
single {
val configFilePath = get<Dotenv>()["CONFIG_FILE_PATH"]
val configFile = File(configFilePath).inputStream()
Toml.decodeFromStream<ConfigurationRepository>(configFile)
}
singleOf(::AnnouncementRepository)
}
val serviceModule = module {
single {
val dotenv = get<Dotenv>()
val jwtSecret = dotenv["JWT_SECRET"]
val issuer = dotenv["JWT_ISSUER"]
val validityInMin = dotenv["JWT_VALIDITY_IN_MIN"].toInt()
val basicUsername = dotenv["BASIC_USERNAME"]
val basicPassword = dotenv["BASIC_PASSWORD"]
AuthService(issuer, validityInMin, jwtSecret, basicUsername, basicPassword)
}
single {
val token = get<Dotenv>()["GITHUB_TOKEN"]
GitHubBackendRepository(token)
} bind BackendRepository::class
singleOf(::AnnouncementService)
singleOf(::PatchesService)
singleOf(::ApiService)
}
install(Koin) {
modules(
globalModule,
repositoryModule,
serviceModule,
)
}
}

View File

@ -1,4 +1,4 @@
package app.revanced.api.modules
package app.revanced.api.configuration
import io.ktor.http.*
import io.ktor.http.content.*
@ -8,7 +8,9 @@ import io.ktor.server.plugins.conditionalheaders.*
import io.ktor.server.plugins.cors.routing.*
import kotlin.time.Duration.Companion.minutes
fun Application.configureHTTP() {
fun Application.configureHTTP(
allowedHost: String,
) {
install(ConditionalHeaders)
install(CORS) {
allowMethod(HttpMethod.Options)
@ -16,7 +18,7 @@ fun Application.configureHTTP() {
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowHeader(HttpHeaders.Authorization)
anyHost() // @TODO: Don't do this in production if possible. Try to limit it.
allowHost(allowedHost)
}
install(CachingHeaders) {
options { _, _ -> CachingOptions(CacheControl.MaxAge(maxAgeSeconds = 5.minutes.inWholeSeconds.toInt())) }

View File

@ -0,0 +1,9 @@
package app.revanced.api.configuration
import app.revanced.api.services.AuthService
import io.ktor.server.application.*
import org.koin.ktor.ext.get
fun Application.configureSecurity() {
get<AuthService>().configureSecurity(this)
}

View File

@ -0,0 +1,19 @@
package app.revanced.api.configuration
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
@OptIn(ExperimentalSerializationApi::class)
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(
Json {
namingStrategy = JsonNamingStrategy.SnakeCase
},
)
}
}

View File

@ -0,0 +1,19 @@
package app.revanced.api.configuration.routing
import app.revanced.api.configuration.routing.routes.configureAnnouncementsRoute
import app.revanced.api.configuration.routing.routes.configurePatchesRoute
import app.revanced.api.configuration.routing.routes.configureRootRoute
import app.revanced.api.repository.ConfigurationRepository
import io.ktor.server.application.*
import io.ktor.server.routing.*
import org.koin.ktor.ext.get
internal fun Application.configureRouting() = routing {
val configuration = get<ConfigurationRepository>()
route("/v${configuration.apiVersion}") {
configureRootRoute()
configurePatchesRoute()
configureAnnouncementsRoute()
}
}

View File

@ -0,0 +1,86 @@
package app.revanced.api.configuration.routing.routes
import app.revanced.api.schema.APIAnnouncement
import app.revanced.api.schema.APIAnnouncementArchivedAt
import app.revanced.api.services.AnnouncementService
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.util.*
import org.koin.ktor.ext.get as koinGet
internal fun Route.configureAnnouncementsRoute() = route("/announcements") {
val announcementService = koinGet<AnnouncementService>()
route("/{channel}/latest") {
get("/id") {
val channel: String by call.parameters
call.respond(
announcementService.latestId(channel) ?: return@get call.respond(HttpStatusCode.NotFound),
)
}
get {
val channel: String by call.parameters
call.respond(
announcementService.latest(channel) ?: return@get call.respond(HttpStatusCode.NotFound),
)
}
}
get("/{channel}") {
val channel: String by call.parameters
call.respond(announcementService.all(channel))
}
route("/latest") {
get("/id") {
call.respond(announcementService.latestId() ?: return@get call.respond(HttpStatusCode.NotFound))
}
get {
call.respond(announcementService.latest() ?: return@get call.respond(HttpStatusCode.NotFound))
}
}
get {
call.respond(announcementService.all())
}
authenticate("jwt") {
post {
announcementService.new(call.receive<APIAnnouncement>())
}
post("/{id}/archive") {
val id: Int by call.parameters
val archivedAt = call.receiveNullable<APIAnnouncementArchivedAt>()?.archivedAt
announcementService.archive(id, archivedAt)
}
post("/{id}/unarchive") {
val id: Int by call.parameters
announcementService.unarchive(id)
}
patch("/{id}") {
val id: Int by call.parameters
announcementService.update(id, call.receive<APIAnnouncement>())
}
delete("/{id}") {
val id: Int by call.parameters
announcementService.delete(id)
}
}
}

View File

@ -0,0 +1,41 @@
package app.revanced.api.configuration.routing.routes
import app.revanced.api.services.ApiService
import app.revanced.api.services.AuthService
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.http.content.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.koin.ktor.ext.get
internal fun Route.configureRootRoute() {
val apiService = get<ApiService>()
val authService = get<AuthService>()
get("/contributors") {
call.respond(apiService.contributors())
}
get("/team") {
call.respond(apiService.team())
}
route("/ping") {
handle {
call.respond(HttpStatusCode.NoContent)
}
}
authenticate("basic") {
get("/token") {
call.respond(authService.newToken())
}
}
staticResources("/", "/static/api") {
contentType { ContentType.Application.Json }
extensions("json")
}
}

View File

@ -0,0 +1,26 @@
package app.revanced.api.configuration.routing.routes
import app.revanced.api.services.PatchesService
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.koin.ktor.ext.get as koinGet
internal fun Route.configurePatchesRoute() = route("/patches") {
val patchesService = koinGet<PatchesService>()
route("latest") {
get {
call.respond(patchesService.latestRelease())
}
get("/version") {
call.respond(patchesService.latestVersion())
}
get("/list") {
call.respondBytes(ContentType.Application.Json) { patchesService.list() }
}
}
}

View File

@ -1,75 +0,0 @@
package app.revanced.api.modules
import app.revanced.api.backend.Backend
import app.revanced.api.backend.github.GitHubBackend
import app.revanced.api.schema.APIConfiguration
import com.akuleshov7.ktoml.Toml
import com.akuleshov7.ktoml.source.decodeFromStream
import io.github.cdimascio.dotenv.Dotenv
import io.ktor.server.application.*
import org.jetbrains.exposed.sql.Database
import org.koin.dsl.bind
import org.koin.dsl.module
import org.koin.ktor.plugin.Koin
import java.io.File
fun Application.configureDependencies() {
install(Koin) {
modules(
globalModule,
gitHubBackendModule,
databaseModule,
authModule,
)
}
}
val globalModule = module {
single {
Dotenv.configure()
.systemProperties()
.load()
}
single {
val configFilePath = get<Dotenv>()["CONFIG_FILE_PATH"]
Toml.decodeFromStream<APIConfiguration>(File(configFilePath).inputStream())
}
}
val gitHubBackendModule = module {
single {
val token = get<Dotenv>()["GITHUB_TOKEN"]
GitHubBackend(token)
} bind Backend::class
}
val databaseModule = module {
single {
val dotenv = get<Dotenv>()
Database.connect(
url = dotenv["DB_URL"],
user = dotenv["DB_USER"],
password = dotenv["DB_PASSWORD"],
driver = "org.h2.Driver",
)
}
factory<AnnouncementService> {
AnnouncementService(get())
}
}
val authModule = module {
single {
val dotenv = get<Dotenv>()
val jwtSecret = dotenv["JWT_SECRET"]
val issuer = dotenv["JWT_ISSUER"]
val validityInMin = dotenv["JWT_VALIDITY_IN_MIN"].toInt()
val basicUsername = dotenv["BASIC_USERNAME"]
val basicPassword = dotenv["BASIC_PASSWORD"]
AuthService(issuer, validityInMin, jwtSecret, basicUsername, basicPassword)
}
}

View File

@ -1,232 +0,0 @@
package app.revanced.api.modules
import app.revanced.api.backend.Backend
import app.revanced.api.schema.*
import app.revanced.library.PatchUtils
import app.revanced.patcher.PatchBundleLoader
import com.github.benmanes.caffeine.cache.Caffeine
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.http.content.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.util.pipeline.*
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import java.io.ByteArrayOutputStream
import java.net.URL
import org.koin.ktor.ext.get as koinGet
fun Application.configureRouting() {
val backend: Backend = koinGet()
val configuration: APIConfiguration = koinGet()
val announcementService: AnnouncementService = koinGet()
val authService: AuthService = koinGet()
routing {
route("/v${configuration.apiVersion}") {
route("/announcements") {
suspend fun PipelineContext<*, ApplicationCall>.announcement(block: AnnouncementService.() -> APIResponseAnnouncement?) =
announcementService.block()?.let { call.respond(it) }
?: call.respond(HttpStatusCode.NotFound)
suspend fun PipelineContext<*, ApplicationCall>.announcementId(block: AnnouncementService.() -> APILatestAnnouncement?) =
announcementService.block()?.let { call.respond(it) }
?: call.respond(HttpStatusCode.NotFound)
suspend fun PipelineContext<*, ApplicationCall>.channel(block: suspend (String) -> Unit) =
block(call.parameters["channel"]!!)
route("/{channel}/latest") {
get("/id") {
channel {
announcementId {
latestId(it)
}
}
}
get {
channel {
announcement {
latest(it)
}
}
}
}
get("/{channel}") {
channel {
call.respond(announcementService.read(it))
}
}
route("/latest") {
get("/id") {
announcementId {
latestId()
}
}
get {
announcement {
latest()
}
}
}
get {
call.respond(announcementService.read())
}
authenticate("jwt") {
suspend fun PipelineContext<*, ApplicationCall>.id(block: suspend (Int) -> Unit) =
call.parameters["id"]!!.toIntOrNull()?.let {
block(it)
} ?: call.respond(HttpStatusCode.BadRequest)
post {
announcementService.new(call.receive<APIAnnouncement>())
}
post("/{id}/archive") {
id {
val archivedAt = call.receiveNullable<APIAnnouncementArchivedAt>()?.archivedAt
announcementService.archive(it, archivedAt)
}
}
post("/{id}/unarchive") {
id {
announcementService.unarchive(it)
}
}
patch("/{id}") {
id {
announcementService.update(it, call.receive<APIAnnouncement>())
}
}
delete("/{id}") {
id {
announcementService.delete(it)
}
}
}
}
route("/patches") {
route("latest") {
get {
val patchesRelease =
backend.getRelease(configuration.organization, configuration.patchesRepository)
val integrationsReleases = configuration.integrationsRepositoryNames.map {
async { backend.getRelease(configuration.organization, it) }
}.awaitAll()
val assets = (patchesRelease.assets + integrationsReleases.flatMap { it.assets })
.map { APIAsset(it.downloadUrl) }
.filter { it.type != APIAsset.Type.UNKNOWN }
.toSet()
val apiRelease = APIRelease(
patchesRelease.tag,
patchesRelease.createdAt,
patchesRelease.releaseNote,
assets,
)
call.respond(apiRelease)
}
get("/version") {
val patchesRelease =
backend.getRelease(configuration.organization, configuration.patchesRepository)
val apiPatchesRelease = APIReleaseVersion(patchesRelease.tag)
call.respond(apiPatchesRelease)
}
val patchesListCache = Caffeine
.newBuilder()
.maximumSize(1)
.build<String, ByteArray>()
get("/list") {
val patchesRelease =
backend.getRelease(configuration.organization, configuration.patchesRepository)
val patchesListByteArray = patchesListCache.getIfPresent(patchesRelease.tag) ?: run {
val downloadUrl = patchesRelease.assets
.map { APIAsset(it.downloadUrl) }
.find { it.type == APIAsset.Type.PATCHES }
?.downloadUrl
val patches = kotlin.io.path.createTempFile().toFile().apply {
outputStream().use { URL(downloadUrl).openStream().copyTo(it) }
}.let { file ->
PatchBundleLoader.Jar(file).also { file.delete() }
}
ByteArrayOutputStream().use { stream ->
PatchUtils.Json.serialize(patches, outputStream = stream)
stream.toByteArray()
}.also {
patchesListCache.put(patchesRelease.tag, it)
}
}
call.respondBytes(ContentType.Application.Json) { patchesListByteArray }
}
}
}
staticResources("/", "/static/api") {
contentType { ContentType.Application.Json }
extensions("json")
}
get("/contributors") {
val contributors =
configuration.contributorsRepositoryNames.map {
async {
APIContributable(
it,
backend.getContributors(configuration.organization, it).map {
APIContributor(it.name, it.avatarUrl, it.url, it.contributions)
}.toSet(),
)
}
}.awaitAll()
call.respond(contributors)
}
get("/team") {
val team =
backend.getMembers(configuration.organization).map {
APIMember(it.name, it.avatarUrl, it.url, it.gpgKeysUrl)
}
call.respond(team)
}
route("/ping") {
handle {
call.respond(HttpStatusCode.NoContent)
}
}
authenticate("basic") {
get("/token") {
call.respond(authService.newToken())
}
}
}
}
}

View File

@ -1,11 +0,0 @@
package app.revanced.api.modules
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
fun Application.configureSerialization() {
install(ContentNegotiation) {
json()
}
}

View File

@ -1,6 +1,6 @@
package app.revanced.api.modules
package app.revanced.api.repository
import app.revanced.api.modules.AnnouncementService.Attachments.announcement
import app.revanced.api.repository.AnnouncementRepository.AttachmentTable.announcement
import app.revanced.api.schema.APIAnnouncement
import app.revanced.api.schema.APILatestAnnouncement
import app.revanced.api.schema.APIResponseAnnouncement
@ -10,96 +10,54 @@ import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
import org.jetbrains.exposed.sql.transactions.transaction
class AnnouncementService(private val database: Database) {
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")
val archivedAt = datetime("archivedAt").nullable()
val level = integer("level")
}
private object Attachments : IntIdTable() {
val url = varchar("url", 256)
val announcement = reference("announcement", Announcements, onDelete = ReferenceOption.CASCADE)
}
class Announcement(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<Announcement>(Announcements)
var author by Announcements.author
var title by Announcements.title
var content by Announcements.content
val attachments by Attachment referrersOn announcement
var channel by Announcements.channel
var createdAt by Announcements.createdAt
var archivedAt by Announcements.archivedAt
var level by Announcements.level
fun api() = APIResponseAnnouncement(
id.value,
author,
title,
content,
attachments.map(Attachment::url).toSet(),
channel,
createdAt,
archivedAt,
level,
)
}
class Attachment(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<Attachment>(Attachments)
var url by Attachments.url
var announcement by Announcement referencedOn Attachments.announcement
}
internal class AnnouncementRepository(private val database: Database) {
init {
transaction {
SchemaUtils.create(Announcements, Attachments)
SchemaUtils.create(AnnouncementTable, AttachmentTable)
}
}
private fun <T> transaction(block: Transaction.() -> T) = transaction(database, block)
fun read() = transaction {
Announcement.all().map { it.api() }.toSet()
fun all() = transaction {
buildSet {
AnnouncementEntity.all().forEach { announcement ->
add(announcement.toApi())
}
}
}
fun read(channel: String) = transaction {
Announcement.find { Announcements.channel eq channel }.map { it.api() }.toSet()
fun all(channel: String) = transaction {
buildSet {
AnnouncementEntity.find { AnnouncementTable.channel eq channel }.forEach { announcement ->
add(announcement.toApi())
}
}
}
fun delete(id: Int) = transaction {
val announcement = Announcement.findById(id) ?: return@transaction
val announcement = AnnouncementEntity.findById(id) ?: return@transaction
announcement.delete()
}
fun latest() = transaction {
Announcement.all().maxByOrNull { it.createdAt }?.api()
AnnouncementEntity.all().maxByOrNull { it.createdAt }?.toApi()
}
fun latest(channel: String) = transaction {
Announcement.find { Announcements.channel eq channel }.maxByOrNull { it.createdAt }?.api()
AnnouncementEntity.find { AnnouncementTable.channel eq channel }.maxByOrNull { it.createdAt }?.toApi()
}
fun latestId() = transaction {
Announcement.all().maxByOrNull { it.createdAt }?.id?.value?.let {
AnnouncementEntity.all().maxByOrNull { it.createdAt }?.id?.value?.let {
APILatestAnnouncement(it)
}
}
fun latestId(channel: String) = transaction {
Announcement.find { Announcements.channel eq channel }.maxByOrNull { it.createdAt }?.id?.value?.let {
AnnouncementEntity.find { AnnouncementTable.channel eq channel }.maxByOrNull { it.createdAt }?.id?.value?.let {
APILatestAnnouncement(it)
}
}
@ -108,19 +66,19 @@ class AnnouncementService(private val database: Database) {
id: Int,
archivedAt: LocalDateTime?,
) = transaction {
Announcement.findById(id)?.apply {
AnnouncementEntity.findById(id)?.apply {
this.archivedAt = archivedAt ?: java.time.LocalDateTime.now().toKotlinLocalDateTime()
}
}
fun unarchive(id: Int) = transaction {
Announcement.findById(id)?.apply {
AnnouncementEntity.findById(id)?.apply {
archivedAt = null
}
}
fun new(new: APIAnnouncement) = transaction {
Announcement.new announcement@{
AnnouncementEntity.new announcement@{
author = new.author
title = new.title
content = new.content
@ -130,7 +88,7 @@ class AnnouncementService(private val database: Database) {
level = new.level
}.also { newAnnouncement ->
new.attachmentUrls.map {
Attachment.new {
AttachmentEntity.new {
url = it
announcement = newAnnouncement
}
@ -139,7 +97,7 @@ class AnnouncementService(private val database: Database) {
}
fun update(id: Int, new: APIAnnouncement) = transaction {
Announcement.findById(id)?.apply {
AnnouncementEntity.findById(id)?.apply {
author = new.author
title = new.title
content = new.content
@ -147,13 +105,66 @@ class AnnouncementService(private val database: Database) {
archivedAt = new.archivedAt
level = new.level
attachments.forEach(Attachment::delete)
attachments.forEach(AttachmentEntity::delete)
new.attachmentUrls.map {
Attachment.new {
AttachmentEntity.new {
url = it
announcement = this@apply
}
}
}
}
private fun <T> transaction(block: Transaction.() -> T) = transaction(database, block)
private object AnnouncementTable : 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")
val archivedAt = datetime("archivedAt").nullable()
val level = integer("level")
}
private object AttachmentTable : IntIdTable() {
val url = varchar("url", 256)
val announcement = reference("announcement", AnnouncementTable, onDelete = ReferenceOption.CASCADE)
}
class AnnouncementEntity(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<AnnouncementEntity>(AnnouncementTable)
var author by AnnouncementTable.author
var title by AnnouncementTable.title
var content by AnnouncementTable.content
val attachments by AttachmentEntity referrersOn announcement
var channel by AnnouncementTable.channel
var createdAt by AnnouncementTable.createdAt
var archivedAt by AnnouncementTable.archivedAt
var level by AnnouncementTable.level
fun toApi() = APIResponseAnnouncement(
id.value,
author,
title,
content,
attachmentUrls = buildSet {
attachments.forEach {
add(it.url)
}
},
channel,
createdAt,
archivedAt,
level,
)
}
class AttachmentEntity(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<AttachmentEntity>(AttachmentTable)
var url by AttachmentTable.url
var announcement by AnnouncementEntity referencedOn AttachmentTable.announcement
}
}

View File

@ -1,10 +1,10 @@
package app.revanced.api.schema
package app.revanced.api.repository
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
class APIConfiguration(
internal class ConfigurationRepository(
val organization: String,
@SerialName("patches-repository")
val patchesRepository: String,

View File

@ -1,4 +1,4 @@
package app.revanced.api.backend
package app.revanced.api.repository.backend
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable
*
* @param httpClientConfig The configuration of the HTTP client.
*/
abstract class Backend(
abstract class BackendRepository internal constructor(
httpClientConfig: HttpClientConfig<OkHttpConfig>.() -> Unit = {},
) {
protected val client: HttpClient = HttpClient(OkHttp, httpClientConfig)
@ -114,7 +114,7 @@ abstract class Backend(
* @param tag The tag of the release. If null, the latest release is returned.
* @return The release.
*/
abstract suspend fun getRelease(
abstract suspend fun release(
owner: String,
repository: String,
tag: String? = null,
@ -127,7 +127,7 @@ abstract class Backend(
* @param repository The name of the repository.
* @return The contributors.
*/
abstract suspend fun getContributors(owner: String, repository: String): Set<BackendOrganization.BackendRepository.BackendContributor>
abstract suspend fun contributors(owner: String, repository: String): Set<BackendOrganization.BackendRepository.BackendContributor>
/**
* Get the members of an organization.
@ -135,5 +135,5 @@ abstract class Backend(
* @param organization The name of the organization.
* @return The members.
*/
abstract suspend fun getMembers(organization: String): Set<BackendOrganization.BackendMember>
abstract suspend fun members(organization: String): Set<BackendOrganization.BackendMember>
}

View File

@ -1,18 +1,18 @@
package app.revanced.api.backend.github
package app.revanced.api.repository.backend.github
import app.revanced.api.backend.Backend
import app.revanced.api.backend.Backend.BackendOrganization.BackendMember
import app.revanced.api.backend.Backend.BackendOrganization.BackendRepository.BackendContributor
import app.revanced.api.backend.Backend.BackendOrganization.BackendRepository.BackendRelease
import app.revanced.api.backend.Backend.BackendOrganization.BackendRepository.BackendRelease.BackendAsset
import app.revanced.api.backend.github.api.Request
import app.revanced.api.backend.github.api.Request.Organization.Members
import app.revanced.api.backend.github.api.Request.Organization.Repository.Contributors
import app.revanced.api.backend.github.api.Request.Organization.Repository.Releases
import app.revanced.api.backend.github.api.Response
import app.revanced.api.backend.github.api.Response.GitHubOrganization.GitHubMember
import app.revanced.api.backend.github.api.Response.GitHubOrganization.GitHubRepository.GitHubContributor
import app.revanced.api.backend.github.api.Response.GitHubOrganization.GitHubRepository.GitHubRelease
import app.revanced.api.repository.backend.BackendRepository
import app.revanced.api.repository.backend.BackendRepository.BackendOrganization.BackendMember
import app.revanced.api.repository.backend.BackendRepository.BackendOrganization.BackendRepository.BackendContributor
import app.revanced.api.repository.backend.BackendRepository.BackendOrganization.BackendRepository.BackendRelease
import app.revanced.api.repository.backend.BackendRepository.BackendOrganization.BackendRepository.BackendRelease.BackendAsset
import app.revanced.api.repository.backend.github.api.Request
import app.revanced.api.repository.backend.github.api.Request.Organization.Members
import app.revanced.api.repository.backend.github.api.Request.Organization.Repository.Contributors
import app.revanced.api.repository.backend.github.api.Request.Organization.Repository.Releases
import app.revanced.api.repository.backend.github.api.Response
import app.revanced.api.repository.backend.github.api.Response.GitHubOrganization.GitHubMember
import app.revanced.api.repository.backend.github.api.Response.GitHubOrganization.GitHubRepository.GitHubContributor
import app.revanced.api.repository.backend.github.api.Response.GitHubOrganization.GitHubRepository.GitHubRelease
import io.ktor.client.call.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.auth.*
@ -30,7 +30,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
@OptIn(ExperimentalSerializationApi::class)
class GitHubBackend(token: String? = null) : Backend({
class GitHubBackendRepository(token: String? = null) : BackendRepository({
install(HttpCache)
install(Resources)
install(ContentNegotiation) {
@ -59,7 +59,7 @@ class GitHubBackend(token: String? = null) : Backend({
}
}
}) {
override suspend fun getRelease(
override suspend fun release(
owner: String,
repository: String,
tag: String?,
@ -80,7 +80,7 @@ class GitHubBackend(token: String? = null) : Backend({
)
}
override suspend fun getContributors(
override suspend fun contributors(
owner: String,
repository: String,
): Set<BackendContributor> {
@ -96,7 +96,7 @@ class GitHubBackend(token: String? = null) : Backend({
}.toSet()
}
override suspend fun getMembers(organization: String): Set<BackendMember> {
override suspend fun members(organization: String): Set<BackendMember> {
// Get the list of members of the organization.
val members: Set<GitHubMember> = client.get(Members(organization)).body()

View File

@ -1,4 +1,4 @@
package app.revanced.api.backend.github.api
package app.revanced.api.repository.backend.github.api
import io.ktor.resources.*

View File

@ -1,4 +1,4 @@
package app.revanced.api.backend.github.api
package app.revanced.api.repository.backend.github.api
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable

View File

@ -0,0 +1,35 @@
package app.revanced.api.services
import app.revanced.api.repository.AnnouncementRepository
import app.revanced.api.schema.APIAnnouncement
import app.revanced.api.schema.APILatestAnnouncement
import kotlinx.datetime.LocalDateTime
internal class AnnouncementService(
private val announcementRepository: AnnouncementRepository,
) {
fun latestId(channel: String): APILatestAnnouncement? = announcementRepository.latestId(channel)
fun latestId(): APILatestAnnouncement? = announcementRepository.latestId()
fun latest(channel: String) = announcementRepository.latest(channel)
fun latest() = announcementRepository.latest()
fun all(channel: String) = announcementRepository.all(channel)
fun all() = announcementRepository.all()
fun new(new: APIAnnouncement) {
announcementRepository.new(new)
}
fun archive(id: Int, archivedAt: LocalDateTime?) {
announcementRepository.archive(id, archivedAt)
}
fun unarchive(id: Int) {
announcementRepository.unarchive(id)
}
fun update(id: Int, new: APIAnnouncement) {
announcementRepository.update(id, new)
}
fun delete(id: Int) {
announcementRepository.delete(id)
}
}

View File

@ -0,0 +1,33 @@
package app.revanced.api.services
import app.revanced.api.repository.ConfigurationRepository
import app.revanced.api.repository.backend.BackendRepository
import app.revanced.api.schema.APIContributable
import app.revanced.api.schema.APIContributor
import app.revanced.api.schema.APIMember
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
internal class ApiService(
private val backendRepository: BackendRepository,
private val configurationRepository: ConfigurationRepository,
) {
suspend fun contributors() = withContext(Dispatchers.IO) {
configurationRepository.contributorsRepositoryNames.map {
async {
APIContributable(
it,
backendRepository.contributors(configurationRepository.organization, it).map {
APIContributor(it.name, it.avatarUrl, it.url, it.contributions)
}.toSet(),
)
}
}
}.awaitAll()
suspend fun team() = backendRepository.members(configurationRepository.organization).map {
APIMember(it.name, it.avatarUrl, it.url, it.gpgKeysUrl)
}
}

View File

@ -1,15 +1,14 @@
package app.revanced.api.modules
package app.revanced.api.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 org.koin.ktor.ext.get
import java.util.*
import kotlin.time.Duration.Companion.minutes
class AuthService(
internal class AuthService(
private val issuer: String,
private val validityInMin: Int,
private val jwtSecret: String,
@ -46,8 +45,3 @@ class AuthService(
.sign(Algorithm.HMAC256(jwtSecret))
}
}
fun Application.configureSecurity() {
val configureSecurity = get<AuthService>().configureSecurity
configureSecurity()
}

View File

@ -0,0 +1,87 @@
package app.revanced.api.services
import app.revanced.api.repository.ConfigurationRepository
import app.revanced.api.repository.backend.BackendRepository
import app.revanced.api.schema.APIAsset
import app.revanced.api.schema.APIRelease
import app.revanced.api.schema.APIReleaseVersion
import app.revanced.library.PatchUtils
import app.revanced.patcher.PatchBundleLoader
import com.github.benmanes.caffeine.cache.Caffeine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.net.URL
internal class PatchesService(
private val backendRepository: BackendRepository,
private val configurationRepository: ConfigurationRepository,
) {
private val patchesListCache = Caffeine
.newBuilder()
.maximumSize(1)
.build<String, ByteArray>()
suspend fun latestRelease(): APIRelease {
val patchesRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.patchesRepository,
)
val integrationsReleases = withContext(Dispatchers.Default) {
configurationRepository.integrationsRepositoryNames.map {
async { backendRepository.release(configurationRepository.organization, it) }
}
}.awaitAll()
val assets = (patchesRelease.assets + integrationsReleases.flatMap { it.assets })
.map { APIAsset(it.downloadUrl) }
.filter { it.type != APIAsset.Type.UNKNOWN }
.toSet()
return APIRelease(
patchesRelease.tag,
patchesRelease.createdAt,
patchesRelease.releaseNote,
assets,
)
}
suspend fun latestVersion(): APIReleaseVersion {
val patchesRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.patchesRepository,
)
return APIReleaseVersion(patchesRelease.tag)
}
suspend fun list(): ByteArray {
val patchesRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.patchesRepository,
)
return patchesListCache.getIfPresent(patchesRelease.tag) ?: run {
val downloadUrl = patchesRelease.assets
.map { APIAsset(it.downloadUrl) }
.find { it.type == APIAsset.Type.PATCHES }
?.downloadUrl
val patches = kotlin.io.path.createTempFile().toFile().apply {
outputStream().use { URL(downloadUrl).openStream().copyTo(it) }
}.let { file ->
PatchBundleLoader.Jar(file).also { file.delete() }
}
ByteArrayOutputStream().use { stream ->
PatchUtils.Json.serialize(patches, outputStream = stream)
stream.toByteArray()
}.also {
patchesListCache.put(patchesRelease.tag, it)
}
}
}
}

View File

@ -80,4 +80,4 @@
}
]
}
}
}

View File

@ -1,57 +0,0 @@
package app.revanced
import app.revanced.api.modules.*
import app.revanced.api.schema.APIConfiguration
import com.akuleshov7.ktoml.Toml
import io.github.cdimascio.dotenv.Dotenv
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import io.ktor.util.*
import io.mockk.every
import io.mockk.mockk
import kotlinx.serialization.encodeToString
import kotlin.test.*
class ApplicationTest {
@Test
fun `successfully create a token`() = testApplication {
val apiConfigurationFile = kotlin.io.path.createTempFile().toFile().apply {
Toml.encodeToString(
APIConfiguration(
organization = "ReVanced",
patchesRepository = "",
integrationsRepositoryNames = setOf(),
contributorsRepositoryNames = setOf(),
),
).let(::writeText)
deleteOnExit()
}
val dotenv = mockk<Dotenv>()
every { dotenv[any()] } returns "ReVanced"
every { dotenv["JWT_VALIDITY_IN_MIN"] } returns "5"
every { dotenv["CONFIG_FILE_PATH"] } returns apiConfigurationFile.absolutePath
application {
configureDependencies()
configureHTTP()
configureSerialization()
configureSecurity()
configureRouting()
}
val token = client.get("/v1/token") {
headers {
append(
HttpHeaders.Authorization,
"Basic ${"${dotenv["BASIC_USERNAME"]}:${dotenv["BASIC_PASSWORD"]}".encodeBase64()}",
)
}
}.bodyAsText()
assert(token.isNotEmpty())
}
}