feat: Add GPG key to team members

This commit is contained in:
oSumAtrIX 2024-06-06 23:20:21 +02:00
parent 6b3dbab90b
commit 71f58cf352
No known key found for this signature in database
GPG Key ID: A9B3094ACDB604B4
12 changed files with 261 additions and 190 deletions

View File

@ -1,9 +1,9 @@
package app.revanced.api.configuration
import app.revanced.api.configuration.repository.AnnouncementRepository
import app.revanced.api.configuration.repository.BackendRepository
import app.revanced.api.configuration.repository.ConfigurationRepository
import app.revanced.api.configuration.repository.backend.BackendRepository
import app.revanced.api.configuration.repository.backend.github.GitHubBackendRepository
import app.revanced.api.configuration.repository.GitHubBackendRepository
import app.revanced.api.configuration.services.AnnouncementService
import app.revanced.api.configuration.services.ApiService
import app.revanced.api.configuration.services.AuthService

View File

@ -0,0 +1,7 @@
package app.revanced.api.configuration
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
suspend fun ApplicationCall.respondOrNotFound(value: Any?) = respond(value ?: HttpStatusCode.NotFound)

View File

@ -13,6 +13,7 @@ fun Application.configureSerialization() {
json(
Json {
namingStrategy = JsonNamingStrategy.SnakeCase
explicitNulls = false
},
)
}

View File

@ -1,8 +1,7 @@
package app.revanced.api.configuration.repository.backend
package app.revanced.api.configuration.repository
import io.ktor.client.*
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable
/**
* The backend of the application used to get data for the API.
@ -40,7 +39,7 @@ abstract class BackendRepository internal constructor(
* @property avatarUrl The URL to the avatar of the member.
* @property url 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.
* @property gpgKeys The GPG key of the member.
*/
@Serializable
class BackendMember(
@ -48,8 +47,19 @@ abstract class BackendRepository internal constructor(
override val avatarUrl: String,
override val url: String,
val bio: String?,
val gpgKeysUrl: String,
) : BackendUser
val gpgKeys: GpgKeys,
) : BackendUser {
/**
* The GPG keys of a member.
*
* @property ids The IDs of the GPG keys.
* @property url The URL to the GPG master key.
*/
class GpgKeys(
val ids: Set<String>,
val url: String,
)
}
/**
* A repository of an organization.
@ -67,7 +77,6 @@ abstract class BackendRepository internal constructor(
* @property url The URL to the profile of the contributor.
* @property contributions The number of contributions of the contributor.
*/
@Serializable
class BackendContributor(
override val name: String,
override val avatarUrl: String,
@ -83,7 +92,6 @@ abstract class BackendRepository internal constructor(
* @property createdAt The date and time the release was created.
* @property releaseNote The release note of the release.
*/
@Serializable
class BackendRelease(
val tag: String,
val releaseNote: String,
@ -95,7 +103,6 @@ abstract class BackendRepository internal constructor(
*
* @property downloadUrl The URL to download the asset.
*/
@Serializable
class BackendAsset(
val downloadUrl: String,
)

View File

@ -0,0 +1,203 @@
package app.revanced.api.configuration.repository
import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendMember
import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendContributor
import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendRelease
import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendRelease.BackendAsset
import app.revanced.api.configuration.repository.GitHubOrganization.GitHubRepository.GitHubContributor
import app.revanced.api.configuration.repository.GitHubOrganization.GitHubRepository.GitHubRelease
import app.revanced.api.configuration.repository.Organization.Repository.Contributors
import app.revanced.api.configuration.repository.Organization.Repository.Releases
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.resources.*
import io.ktor.resources.*
import kotlinx.coroutines.*
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.Serializable
class GitHubBackendRepository(client: HttpClient) : BackendRepository(client) {
override suspend fun release(
owner: String,
repository: String,
tag: String?,
): BackendRelease {
val release: GitHubRelease = if (tag != null) {
client.get(Releases.Tag(owner, repository, tag)).body()
} else {
client.get(Releases.Latest(owner, repository)).body()
}
return BackendRelease(
tag = release.tagName,
releaseNote = release.body,
createdAt = release.createdAt.toLocalDateTime(TimeZone.UTC),
assets = release.assets.map {
BackendAsset(downloadUrl = it.browserDownloadUrl)
}.toSet(),
)
}
override suspend fun contributors(
owner: String,
repository: String,
): Set<BackendContributor> {
val contributors: Set<GitHubContributor> = client.get(
Contributors(
owner,
repository,
),
).body()
return contributors.map {
BackendContributor(
name = it.login,
avatarUrl = it.avatarUrl,
url = it.htmlUrl,
contributions = it.contributions,
)
}.toSet()
}
override suspend fun members(organization: String): Set<BackendMember> {
// Get the list of members of the organization.
val members: Set<GitHubOrganization.GitHubMember> = client.get(Organization.Members(organization)).body()
return coroutineScope {
members.map { member ->
async {
awaitAll(
async {
// Get the user.
client.get(User(member.login)).body<GitHubUser>()
},
async {
// Get the GPG key of the user.
client.get(User.GpgKeys(member.login)).body<Set<GitHubUser.GitHubGpgKey>>()
},
)
}
}
}.awaitAll().map { responses ->
val user = responses[0] as GitHubUser
@Suppress("UNCHECKED_CAST")
val gpgKeys = responses[1] as Set<GitHubUser.GitHubGpgKey>
BackendMember(
name = user.login,
avatarUrl = user.avatarUrl,
url = user.htmlUrl,
bio = user.bio,
gpgKeys =
BackendMember.GpgKeys(
ids = gpgKeys.map { it.keyId }.toSet(),
url = "https://api.github.com/users/${user.login}.gpg",
),
)
}.toSet()
}
override suspend fun rateLimit(): BackendRateLimit {
val rateLimit: GitHubRateLimit = client.get(RateLimit()).body()
return BackendRateLimit(
limit = rateLimit.rate.limit,
remaining = rateLimit.rate.remaining,
reset = Instant.fromEpochSeconds(rateLimit.rate.reset).toLocalDateTime(TimeZone.UTC),
)
}
}
interface IGitHubUser {
val login: String
val avatarUrl: String
val htmlUrl: String
}
@Serializable
class GitHubUser(
override val login: String,
override val avatarUrl: String,
override val htmlUrl: String,
val bio: String?,
) : IGitHubUser {
@Serializable
class GitHubGpgKey(
val keyId: String,
)
}
class GitHubOrganization {
@Serializable
class GitHubMember(
override val login: String,
override val avatarUrl: String,
override val htmlUrl: String,
) : IGitHubUser
class GitHubRepository {
@Serializable
class GitHubContributor(
override val login: String,
override val avatarUrl: String,
override val htmlUrl: String,
val contributions: Int,
) : IGitHubUser
@Serializable
class GitHubRelease(
val tagName: String,
val assets: Set<GitHubAsset>,
val createdAt: Instant,
val body: String,
) {
@Serializable
class GitHubAsset(
val browserDownloadUrl: String,
)
}
}
}
@Serializable
class GitHubRateLimit(
val rate: Rate,
) {
@Serializable
class Rate(
val limit: Int,
val remaining: Int,
val reset: Long,
)
}
@Resource("/users/{login}")
class User(val login: String) {
@Resource("/users/{login}/gpg_keys")
class GpgKeys(val login: 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)
}
}
}
@Resource("/rate_limit")
class RateLimit

View File

@ -1,83 +0,0 @@
package app.revanced.api.configuration.repository.backend.github
import app.revanced.api.configuration.repository.backend.BackendRepository
import app.revanced.api.configuration.repository.backend.BackendRepository.BackendOrganization.BackendMember
import app.revanced.api.configuration.repository.backend.BackendRepository.BackendOrganization.BackendRepository.BackendContributor
import app.revanced.api.configuration.repository.backend.BackendRepository.BackendOrganization.BackendRepository.BackendRelease
import app.revanced.api.configuration.repository.backend.BackendRepository.BackendOrganization.BackendRepository.BackendRelease.BackendAsset
import app.revanced.api.configuration.repository.backend.github.api.Request
import app.revanced.api.configuration.repository.backend.github.api.Request.Organization.Members
import app.revanced.api.configuration.repository.backend.github.api.Request.Organization.Repository.Contributors
import app.revanced.api.configuration.repository.backend.github.api.Request.Organization.Repository.Releases
import app.revanced.api.configuration.repository.backend.github.api.Response
import app.revanced.api.configuration.repository.backend.github.api.Response.GitHubOrganization.GitHubMember
import app.revanced.api.configuration.repository.backend.github.api.Response.GitHubOrganization.GitHubRepository.GitHubContributor
import app.revanced.api.configuration.repository.backend.github.api.Response.GitHubOrganization.GitHubRepository.GitHubRelease
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.resources.*
import kotlinx.coroutines.*
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
class GitHubBackendRepository(client: HttpClient) : BackendRepository(client) {
override suspend fun release(
owner: String,
repository: String,
tag: String?,
): BackendRelease {
val release: GitHubRelease = if (tag != null) {
client.get(Releases.Tag(owner, repository, tag)).body()
} else {
client.get(Releases.Latest(owner, repository)).body()
}
return BackendRelease(
tag = release.tagName,
releaseNote = release.body,
createdAt = release.createdAt.toLocalDateTime(TimeZone.UTC),
assets = release.assets.map {
BackendAsset(downloadUrl = it.browserDownloadUrl)
}.toSet(),
)
}
override suspend fun contributors(
owner: String,
repository: String,
): Set<BackendContributor> {
val contributors: Set<GitHubContributor> = client.get(Contributors(owner, repository)).body()
return contributors.map {
BackendContributor(
name = it.login,
avatarUrl = it.avatarUrl,
url = it.url,
contributions = it.contributions,
)
}.toSet()
}
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()
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.GitHubUser>()
}
}
}.awaitAll().map { user ->
// Map the user back to a member.
BackendMember(
name = user.login,
avatarUrl = user.avatarUrl,
url = user.url,
bio = user.bio,
gpgKeysUrl = "https://github.com/${user.login}.gpg",
)
}.toSet()
}
}

View File

@ -1,27 +0,0 @@
package app.revanced.api.configuration.repository.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

@ -1,52 +0,0 @@
package app.revanced.api.configuration.repository.backend.github.api
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
class Response {
interface IGitHubUser {
val login: String
val avatarUrl: String
val url: String
}
@Serializable
class GitHubUser(
override val login: String,
override val avatarUrl: String,
override val url: String,
val bio: String?,
) : IGitHubUser
class GitHubOrganization {
@Serializable
class GitHubMember(
override val login: String,
override val avatarUrl: String,
override val url: String,
) : IGitHubUser
class GitHubRepository {
@Serializable
class GitHubContributor(
override val login: String,
override val avatarUrl: String,
override val url: String,
val contributions: Int,
) : IGitHubUser
@Serializable
class GitHubRelease(
val tagName: String,
val assets: Set<GitHubAsset>,
val createdAt: Instant,
val body: String,
) {
@Serializable
class GitHubAsset(
val browserDownloadUrl: String,
)
}
}
}
}

View File

@ -1,9 +1,9 @@
package app.revanced.api.configuration.routing.routes
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.services.AnnouncementService
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
@ -19,17 +19,13 @@ internal fun Route.announcementsRoute() = route("announcements") {
get("id") {
val channel: String by call.parameters
call.respond(
announcementService.latestId(channel) ?: return@get call.respond(HttpStatusCode.NotFound),
)
call.respondOrNotFound(announcementService.latestId(channel))
}
get {
val channel: String by call.parameters
call.respond(
announcementService.latest(channel) ?: return@get call.respond(HttpStatusCode.NotFound),
)
call.respondOrNotFound(announcementService.latest(channel))
}
}
@ -41,11 +37,11 @@ internal fun Route.announcementsRoute() = route("announcements") {
route("latest") {
get("id") {
call.respond(announcementService.latestId() ?: return@get call.respond(HttpStatusCode.NotFound))
call.respondOrNotFound(announcementService.latestId())
}
get {
call.respond(announcementService.latest() ?: return@get call.respond(HttpStatusCode.NotFound))
call.respondOrNotFound(announcementService.latest())
}
}
@ -73,8 +69,9 @@ internal fun Route.announcementsRoute() = route("announcements") {
patch("{id}") {
val id: Int by call.parameters
val announcement = call.receive<APIAnnouncement>()
announcementService.update(id, call.receive<APIAnnouncement>())
announcementService.update(id, announcement)
}
delete("{id}") {

View File

@ -23,9 +23,15 @@ class APIMember(
override val name: String,
override val avatarUrl: String,
override val url: String,
val gpgKeysUrl: String,
val gpgKey: APIGpgKey?,
) : APIUser
@Serializable
class APIGpgKey(
val id: String,
val url: String,
)
@Serializable
class APIContributor(
override val name: String,

View File

@ -1,10 +1,8 @@
package app.revanced.api.configuration.services
import app.revanced.api.configuration.repository.BackendRepository
import app.revanced.api.configuration.repository.ConfigurationRepository
import app.revanced.api.configuration.repository.backend.BackendRepository
import app.revanced.api.configuration.schema.APIContributable
import app.revanced.api.configuration.schema.APIContributor
import app.revanced.api.configuration.schema.APIMember
import app.revanced.api.configuration.schema.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@ -27,7 +25,21 @@ internal class ApiService(
}
}.awaitAll()
suspend fun team() = backendRepository.members(configurationRepository.organization).map {
APIMember(it.name, it.avatarUrl, it.url, it.gpgKeysUrl)
suspend fun team() = backendRepository.members(configurationRepository.organization).map { member ->
APIMember(
member.name,
member.avatarUrl,
member.url,
if (member.gpgKeys.ids.isNotEmpty()) {
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,
)
} else {
null
},
)
}
}

View File

@ -1,7 +1,7 @@
package app.revanced.api.configuration.services
import app.revanced.api.configuration.repository.BackendRepository
import app.revanced.api.configuration.repository.ConfigurationRepository
import app.revanced.api.configuration.repository.backend.BackendRepository
import app.revanced.api.configuration.schema.APIAsset
import app.revanced.api.configuration.schema.APIRelease
import app.revanced.api.configuration.schema.APIReleaseVersion