feat: Initialize project

This commit is contained in:
oSumAtrIX
2024-02-01 04:12:05 +01:00
parent bf5eaa8940
commit 8ae50b543e
81 changed files with 4005 additions and 6302 deletions

View File

@@ -0,0 +1,24 @@
package app.revanced.api
import app.revanced.api.plugins.*
import io.github.cdimascio.dotenv.Dotenv
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
fun main() {
Dotenv.load()
embeddedServer(Netty, port = 8080, host = "0.0.0.0", configure = {
connectionGroupSize = 1
workerGroupSize = 1
callGroupSize = 1
}) {
configureHTTP()
configureSerialization()
configureDatabases()
configureSecurity()
configureDependencies()
configureRouting()
}.start(wait = true)
}

View File

@@ -0,0 +1,140 @@
package app.revanced.api.backend
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import kotlinx.serialization.Serializable
/**
* The backend of the application used to get data for the API.
*
* @param httpClientConfig The configuration of the HTTP client.
*/
abstract class Backend(
httpClientConfig: HttpClientConfig<OkHttpConfig>.() -> Unit = {}
) {
protected val client: HttpClient = HttpClient(OkHttp, httpClientConfig)
/**
* A user.
*
* @property name The name of the user.
* @property avatarUrl The URL to the avatar of the user.
* @property profileUrl The URL to the profile of the user.
*/
interface User {
val name: String
val avatarUrl: String
val profileUrl: String
}
/**
* An organization.
*
* @property members The members of the organization.
*/
class Organization(
val members: Set<Member>
) {
/**
* A member of an organization.
*
* @property name The name of the member.
* @property avatarUrl The URL to the avatar of the member.
* @property profileUrl The URL to the profile of the member.
* @property bio The bio of the member.
* @property gpgKeysUrl The URL to the GPG keys of the member.
*/
@Serializable
class Member (
override val name: String,
override val avatarUrl: String,
override val profileUrl: String,
val bio: String?,
val gpgKeysUrl: String?
) : User
/**
* A repository of an organization.
*
* @property contributors The contributors of the repository.
*/
class Repository(
val contributors: Set<Contributor>
) {
/**
* A contributor of a repository.
*
* @property name The name of the contributor.
* @property avatarUrl The URL to the avatar of the contributor.
* @property profileUrl The URL to the profile of the contributor.
*/
@Serializable
class Contributor(
override val name: String,
override val avatarUrl: String,
override val profileUrl: String
) : User
/**
* A release of a repository.
*
* @property tag The tag of the release.
* @property assets The assets of the release.
* @property createdAt The date and time the release was created.
* @property releaseNote The release note of the release.
*/
@Serializable
class Release(
val tag: String,
val releaseNote: String,
val createdAt: String,
val assets: Set<Asset>
) {
/**
* An asset of a release.
*
* @property downloadUrl The URL to download the asset.
*/
@Serializable
class Asset(
val downloadUrl: String
)
}
}
}
/**
* Get a release of a repository.
*
* @param owner The owner of the repository.
* @param repository The name of the repository.
* @param tag The tag of the release. If null, the latest release is returned.
* @param preRelease Whether to return a pre-release.
* If no pre-release exists, the latest release is returned.
* If tag is not null, this parameter is ignored.
* @return The release.
*/
abstract suspend fun getRelease(
owner: String,
repository: String,
tag: String? = null,
preRelease: Boolean = false
): Organization.Repository.Release
/**
* Get the contributors of a repository.
*
* @param owner The owner of the repository.
* @param repository The name of the repository.
* @return The contributors.
*/
abstract suspend fun getContributors(owner: String, repository: String): Set<Organization.Repository.Contributor>
/**
* Get the members of an organization.
*
* @param organization The name of the organization.
* @return The members.
*/
abstract suspend fun getMembers(organization: String): Set<Organization.Member>
}

View File

@@ -0,0 +1,116 @@
package app.revanced.api.backend.github
import app.revanced.api.backend.Backend
import app.revanced.api.backend.github.api.Request
import io.ktor.client.call.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.cache.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.resources.*
import app.revanced.api.backend.github.api.Request.Organization.Repository.Releases
import app.revanced.api.backend.github.api.Request.Organization.Repository.Contributors
import app.revanced.api.backend.github.api.Request.Organization.Members
import app.revanced.api.backend.github.api.Response
import app.revanced.api.backend.github.api.Response.Organization.Repository.Release
import app.revanced.api.backend.github.api.Response.Organization.Repository.Contributor
import app.revanced.api.backend.github.api.Response.Organization.Member
import io.ktor.client.plugins.resources.Resources
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.*
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
@OptIn(ExperimentalSerializationApi::class)
class GitHubBackend(token: String? = null) : Backend({
install(HttpCache)
install(Resources)
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
namingStrategy = JsonNamingStrategy.SnakeCase
})
}
defaultRequest { url("https://api.github.com") }
token?.let {
install(Auth) {
bearer {
loadTokens {
BearerTokens(
accessToken = it,
refreshToken = "" // Required dummy value
)
}
sendWithoutRequest { true }
}
}
}
}) {
override suspend fun getRelease(
owner: String,
repository: String,
tag: String?,
preRelease: Boolean
): Organization.Repository.Release {
val release = if (preRelease) {
val releases: Set<Release> = client.get(Releases(owner, repository)).body()
releases.firstOrNull { it.preReleases } ?: releases.first() // Latest pre-release or latest release
} else {
client.get(
tag?.let { Releases.Tag(owner, repository, it) }
?: Releases.Latest(owner, repository)
).body()
}
return Organization.Repository.Release(
tag = release.tagName,
releaseNote = release.body,
createdAt = release.createdAt,
assets = release.assets.map {
Organization.Repository.Release.Asset(
downloadUrl = it.browserDownloadUrl
)
}.toSet()
)
}
override suspend fun getContributors(owner: String, repository: String): Set<Organization.Repository.Contributor> {
val contributors: Set<Contributor> = client.get(Contributors(owner, repository)).body()
return contributors.map {
Organization.Repository.Contributor(
name = it.login,
avatarUrl = it.avatarUrl,
profileUrl = it.url
)
}.toSet()
}
override suspend fun getMembers(organization: String): Set<Organization.Member> {
// Get the list of members of the organization.
val members: Set<Member> = client.get(Members(organization)).body<Set<Member>>()
return runBlocking(Dispatchers.Default) {
members.map { member ->
// Map the member to a user in order to get the bio.
async {
client.get(Request.User(member.login)).body<Response.User>()
}
}
}.awaitAll().map { user ->
// Map the user back to a member.
Organization.Member(
name = user.login,
avatarUrl = user.avatarUrl,
profileUrl = user.url,
bio = user.bio,
gpgKeysUrl = "https://github.com/${user.login}.gpg",
)
}.toSet()
}
}

View File

@@ -0,0 +1,26 @@
package app.revanced.api.backend.github.api
import io.ktor.resources.*
class Request {
@Resource("/users/{username}")
class User(val username: String)
class Organization {
@Resource("/orgs/{org}/members")
class Members(val org: String)
class Repository {
@Resource("/repos/{owner}/{repo}/contributors")
class Contributors(val owner: String, val repo: String)
@Resource("/repos/{owner}/{repo}/releases")
class Releases(val owner: String, val repo: String) {
@Resource("/repos/{owner}/{repo}/releases/tags/{tag}")
class Tag(val owner: String, val repo: String, val tag: String)
@Resource("/repos/{owner}/{repo}/releases/latest")
class Latest(val owner: String, val repo: String)
}
}
}
}

View File

@@ -0,0 +1,52 @@
package app.revanced.api.backend.github.api
import kotlinx.serialization.Serializable
class Response {
interface IUser {
val login: String
val avatarUrl: String
val url: String
}
@Serializable
class User (
override val login: String,
override val avatarUrl: String,
override val url: String,
val bio: String?,
) : IUser
class Organization {
@Serializable
class Member(
override val login: String,
override val avatarUrl: String,
override val url: String,
) : IUser
class Repository {
@Serializable
class Contributor(
override val login: String,
override val avatarUrl: String,
override val url: String,
) : IUser
@Serializable
class Release(
val tagName: String,
val assets: Set<Asset>,
val preReleases: Boolean,
val createdAt: String,
val body: String
) {
@Serializable
class Asset(
val browserDownloadUrl: String
)
}
}
}
}

View File

@@ -0,0 +1,49 @@
package app.revanced.api.plugins
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.jetbrains.exposed.sql.*
fun Application.configureDatabases() {
val database = Database.connect(
url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1",
user = "root",
driver = "org.h2.Driver",
password = ""
)
val userService = UserService(database)
routing {
// Create user
post("/users") {
val user = call.receive<ExposedUser>()
val id = userService.create(user)
call.respond(HttpStatusCode.Created, id)
}
// Read user
get("/users/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
val user = userService.read(id)
if (user != null) {
call.respond(HttpStatusCode.OK, user)
} else {
call.respond(HttpStatusCode.NotFound)
}
}
// Update user
put("/users/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
val user = call.receive<ExposedUser>()
userService.update(id, user)
call.respond(HttpStatusCode.OK)
}
// Delete user
delete("/users/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
userService.delete(id)
call.respond(HttpStatusCode.OK)
}
}
}

View File

@@ -0,0 +1,21 @@
package app.revanced.api.plugins
import app.revanced.api.backend.github.GitHubBackend
import io.github.cdimascio.dotenv.Dotenv
import io.ktor.server.application.*
import org.koin.core.context.startKoin
import org.koin.dsl.module
import org.koin.ktor.ext.inject
import org.koin.ktor.plugin.Koin
fun Application.configureDependencies() {
install(Koin) {
modules(
module {
single { Dotenv.load() }
single { GitHubBackend(get<Dotenv>().get("GITHUB_TOKEN")) }
}
)
}
}

View File

@@ -0,0 +1,37 @@
package app.revanced.api.plugins
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.plugins.cachingheaders.*
import io.ktor.server.plugins.conditionalheaders.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.openapi.*
import io.ktor.server.plugins.swagger.*
import io.ktor.server.routing.*
fun Application.configureHTTP() {
install(ConditionalHeaders)
routing {
swaggerUI(path = "openapi")
}
routing {
openAPI(path = "openapi")
}
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowHeader(HttpHeaders.Authorization)
anyHost() // @TODO: Don't do this in production if possible. Try to limit it.
}
install(CachingHeaders) {
options { _, outgoingContent ->
when (outgoingContent.contentType?.withoutParameters()) {
ContentType.Text.CSS -> CachingOptions(CacheControl.MaxAge(maxAgeSeconds = 24 * 60 * 60))
else -> null
}
}
}
}

View File

@@ -0,0 +1,45 @@
package app.revanced.api.plugins
import app.revanced.api.backend.github.GitHubBackend
import io.github.cdimascio.dotenv.Dotenv
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.koin.ktor.ext.inject
fun Application.configureRouting() {
val backend by inject<GitHubBackend>()
val dotenv by inject<Dotenv>()
routing {
route("/v${dotenv.get("API_VERSION", "1")}") {
route("/manager") {
get("/contributors") {
val contributors = backend.getContributors("revanced", "revanced-patches")
call.respond(contributors)
}
get("/members") {
val members = backend.getMembers("revanced")
call.respond(members)
}
}
route("/patches") {
}
route("/ping") {
handle {
call.respond(HttpStatusCode.NoContent)
}
}
}
staticResources("/", "static")
}
}

View File

@@ -0,0 +1,30 @@
package app.revanced.api.plugins
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.*
fun Application.configureSecurity() {
// Please read the jwt property from the config file if you are using EngineMain
val jwtAudience = "jwt-audience"
val jwtDomain = "https://jwt-provider-domain/"
val jwtRealm = "ktor sample app"
val jwtSecret = "secret"
authentication {
jwt {
realm = jwtRealm
verifier(
JWT
.require(Algorithm.HMAC256(jwtSecret))
.withAudience(jwtAudience)
.withIssuer(jwtDomain)
.build()
)
validate { credential ->
if (credential.payload.audience.contains(jwtAudience)) JWTPrincipal(credential.payload) else null
}
}
}
}

View File

@@ -0,0 +1,11 @@
package app.revanced.api.plugins
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

@@ -0,0 +1,59 @@
package app.revanced.api.plugins
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import kotlinx.serialization.Serializable
import kotlinx.coroutines.Dispatchers
import org.jetbrains.exposed.sql.*
@Serializable
data class ExposedUser(val name: String, val age: Int)
class UserService(private val database: Database) {
object Users : Table() {
val id = integer("id").autoIncrement()
val name = varchar("name", length = 50)
val age = integer("age")
override val primaryKey = PrimaryKey(id)
}
init {
transaction(database) {
SchemaUtils.create(Users)
}
}
suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
suspend fun create(user: ExposedUser): Int = dbQuery {
Users.insert {
it[name] = user.name
it[age] = user.age
}[Users.id]
}
suspend fun read(id: Int): ExposedUser? {
return dbQuery {
Users.select { Users.id eq id }
.map { ExposedUser(it[Users.name], it[Users.age]) }
.singleOrNull()
}
}
suspend fun update(id: Int, user: ExposedUser) {
dbQuery {
Users.update({ Users.id eq id }) {
it[name] = user.name
it[age] = user.age
}
}
}
suspend fun delete(id: Int) {
dbQuery {
Users.deleteWhere { Users.id.eq(id) }
}
}
}

View File

@@ -0,0 +1,12 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="trace">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
</configuration>

View File

@@ -0,0 +1,23 @@
openapi: "3.0.3"
info:
title: "Application API"
description: "Application API"
version: "1.0.0"
servers:
- url: "http://0.0.0.0:8080"
paths:
/:
get:
description: "Hello World!"
responses:
"200":
description: "OK"
content:
text/plain:
schema:
type: "string"
examples:
Example#1:
value: "Hello World!"
components:
schemas:

View File

@@ -0,0 +1,99 @@
{
"name": "ReVanced",
"about": "ReVanced was born out of Vanced's discontinuation and it is our goal to continue the legacy of what Vanced left behind. Thanks to ReVanced Patcher, it's possible to create long-lasting patches for nearly any Android app. ReVanced's patching system is designed to allow patches to work on new versions of the apps automatically with bare minimum maintenance.",
"branding":
{
"logo": "https://raw.githubusercontent.com/ReVanced/revanced-branding/main/assets/revanced-logo/revanced-logo.svg"
},
"contact":
{
"email": "contact@revanced.app"
},
"socials":
[
{
"name": "Website",
"url": "https://revanced.app",
"preferred": true
},
{
"name": "GitHub",
"url": "https://github.com/revanced",
"preferred": false
},
{
"name": "Twitter",
"url": "https://twitter.com/revancedapp",
"preferred": false
},
{
"name": "Discord",
"url": "https://revanced.app/discord",
"preferred": true
},
{
"name": "Reddit",
"url": "https://www.reddit.com/r/revancedapp",
"preferred": false
},
{
"name": "Telegram",
"url": "https://t.me/app_revanced",
"preferred": false
},
{
"name": "YouTube",
"url": "https://www.youtube.com/@ReVanced",
"preferred": false
}
],
"donations":
{
"wallets":
[
{
"network": "Bitcoin",
"currency_code": "BTC",
"address": "bc1q4x8j6mt27y5gv0q625t8wkr87ruy8fprpy4v3f",
"preferred": false
},
{
"network": "Dogecoin",
"currency_code": "DOGE",
"address": "D8GH73rNjudgi6bS2krrXWEsU9KShedLXp",
"preferred": true
},
{
"network": "Ethereum",
"currency_code": "ETH",
"address": "0x7ab4091e00363654bf84B34151225742cd92FCE5",
"preferred": false
},
{
"network": "Litecoin",
"currency_code": "LTC",
"address": "LbJi8EuoDcwaZvykcKmcrM74jpjde23qJ2",
"preferred": false
},
{
"network": "Monero",
"currency_code": "XMR",
"address": "46YwWDbZD6jVptuk5mLHsuAmh1BnUMSjSNYacozQQEraWSQ93nb2yYVRHoMR6PmFYWEHsLHg9tr1cH5M8Rtn7YaaGQPCjSh",
"preferred": false
}
],
"links":
[
{
"name": "Open Collective",
"url": "https://opencollective.com/revanced",
"preferred": true
},
{
"name": "GitHub Sponsors",
"url": "https://github.com/sponsors/ReVanced",
"preferred": false
}
]
}
}

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -0,0 +1,21 @@
package app.revanced
import app.revanced.api.plugins.configureRouting
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.*
class ApplicationTest {
@Test
fun testRoot() = testApplication {
application {
configureRouting()
}
client.get("/").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals("Hello World!", bodyAsText())
}
}
}