Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Public extraction and proxy routes:
- `/search`, `/suggestions`, `/trending`
- `/comments`, `/bullet-comments`, `/channel`
- `/proxy`, `/proxy/storyboard`, `/proxy/nicovideo`
- `/health`, `/instance` (public instance metadata from `admin_settings` + Gradle version; `/instance` returns name, tagline, logoUrl, bannerUrl, version, apiVersion, registrationAllowed, guestAllowed, supportedServices, and minClientVersion.android with `Cache-Control: public, max-age=300`)

Protected user routes:

Expand Down
21 changes: 21 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ dependencies {
testImplementation("org.testcontainers:testcontainers-junit-jupiter:2.0.5")
}

val buildInfoVersion = version.toString().trim().takeUnless { it.isBlank() || it == "unspecified" } ?: "0.0.0-dev"
val generatedBuildInfoDir = layout.buildDirectory.dir("generated/sources/buildInfo/main")
val generateBuildInfo = tasks.register("generateBuildInfo") {
inputs.property("version", buildInfoVersion)
outputs.dir(generatedBuildInfoDir)
doLast {
val output = generatedBuildInfoDir.get().file("dev/typetype/server/BuildInfo.kt").asFile
output.parentFile.mkdirs()
output.writeText("""
package dev.typetype.server

object BuildInfo {
const val VERSION: String = "${buildInfoVersion.replace("\\", "\\\\").replace("\"", "\\\"")}"
}
""".trimIndent())
}
}

tasks.test {
useJUnitPlatform {
excludeTags("network")
Expand Down Expand Up @@ -89,8 +107,11 @@ tasks.check {

kotlin {
jvmToolchain(21)
sourceSets.named("main") { kotlin.srcDir(generatedBuildInfoDir) }
}

tasks.named("compileKotlin") { dependsOn(generateBuildInfo) }

tasks.withType<AbstractCopyTask>().configureEach {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
4 changes: 4 additions & 0 deletions src/main/kotlin/dev/typetype/server/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import dev.typetype.server.routes.adminRoutes
import dev.typetype.server.routes.adminBugReportRoutes
import dev.typetype.server.routes.authRoutes
import dev.typetype.server.routes.trendingRoutes
import dev.typetype.server.routes.publicMetadataRoutes
import dev.typetype.server.routes.watchLaterRoutes
import dev.typetype.server.routes.userDataRoutes
import dev.typetype.server.services.AuthService
Expand All @@ -44,6 +45,7 @@ import dev.typetype.server.services.PasswordResetService
import dev.typetype.server.services.ProfileService
import dev.typetype.server.services.PipePipeBackupImporterService
import dev.typetype.server.services.OpenMojiProxyService
import dev.typetype.server.services.InstanceService
import dev.typetype.server.services.UserAdminService
import io.ktor.server.application.Application
import io.ktor.server.netty.EngineMain
Expand Down Expand Up @@ -71,6 +73,7 @@ fun Application.module() {
val avatarService = AvatarService()
val gitHubIssueService = GitHubIssueService()
val adminSettingsService = AdminSettingsService()
val instanceService = InstanceService(authService, adminSettingsService)
val restoreService = PipePipeBackupImporterService()

val cacheUrl = System.getenv("DRAGONFLY_URL") ?: "redis://localhost:6379"
Expand All @@ -84,6 +87,7 @@ fun Application.module() {
configurePlugins(authService)

routing {
publicMetadataRoutes(instanceService::getInstance)
rateLimit(STREAMS_ZONE) {
streamRoutes(svc.streamService)
manifestRoutes(svc.manifestService, svc.nativeManifestService, svc.hlsManifestService)
Expand Down
5 changes: 5 additions & 0 deletions src/main/kotlin/dev/typetype/server/InstanceDefaults.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package dev.typetype.server

const val DEFAULT_INSTANCE_NAME = "TypeType"
const val INSTANCE_API_VERSION = 1
const val INSTANCE_CACHE_CONTROL = "public, max-age=300"
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package dev.typetype.server.db

import dev.typetype.server.DEFAULT_INSTANCE_NAME
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager

object DatabaseSessionAuthMigration {
fun apply() {
exec("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS refresh_token_hash TEXT")
exec("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS created_at BIGINT NOT NULL DEFAULT 0")
exec("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS revoked_at BIGINT")
exec("ALTER TABLE admin_settings ADD COLUMN IF NOT EXISTS name TEXT DEFAULT '$DEFAULT_INSTANCE_NAME'")
exec("ALTER TABLE admin_settings ADD COLUMN IF NOT EXISTS tagline TEXT")
exec("ALTER TABLE admin_settings ADD COLUMN IF NOT EXISTS logo_url TEXT")
exec("ALTER TABLE admin_settings ADD COLUMN IF NOT EXISTS banner_url TEXT")
exec("ALTER TABLE admin_settings ADD COLUMN IF NOT EXISTS min_android_client_version TEXT")
exec("UPDATE sessions SET created_at = CASE WHEN created_at = 0 THEN expires_at - 86400000 ELSE created_at END")
exec("CREATE UNIQUE INDEX IF NOT EXISTS sessions_refresh_token_hash_unique ON sessions (refresh_token_hash)")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
package dev.typetype.server.db.tables

import dev.typetype.server.DEFAULT_INSTANCE_NAME
import org.jetbrains.exposed.v1.core.Table

object AdminSettingsTable : Table("admin_settings") {
val id = integer("id").default(1)
val name = text("name").default(DEFAULT_INSTANCE_NAME)
val tagline = text("tagline").nullable()
val logoUrl = text("logo_url").nullable()
val bannerUrl = text("banner_url").nullable()
val minAndroidClientVersion = text("min_android_client_version").nullable()
val allowRegistration = bool("allow_registration").default(true)
val allowGuest = bool("allow_guest").default(true)
val forceEmailVerification = bool("force_email_verification").default(false)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
package dev.typetype.server.models

import dev.typetype.server.DEFAULT_INSTANCE_NAME
import kotlinx.serialization.Serializable

@Serializable
data class AdminSettingsItem(
val name: String = DEFAULT_INSTANCE_NAME,
val tagline: String? = null,
val logoUrl: String? = null,
val bannerUrl: String? = null,
val minAndroidClientVersion: String? = null,
val allowRegistration: Boolean = true,
val allowGuest: Boolean = true,
val forceEmailVerification: Boolean = false,
Expand Down
8 changes: 8 additions & 0 deletions src/main/kotlin/dev/typetype/server/models/HealthResponse.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package dev.typetype.server.models

import kotlinx.serialization.Serializable

@Serializable
data class HealthResponse(
val status: String = "ok",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package dev.typetype.server.models

import kotlinx.serialization.Serializable

@Serializable
data class InstanceMinClientVersion(
val android: String? = null,
)
17 changes: 17 additions & 0 deletions src/main/kotlin/dev/typetype/server/models/InstanceResponse.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package dev.typetype.server.models

import kotlinx.serialization.Serializable

@Serializable
data class InstanceResponse(
val name: String,
val tagline: String? = null,
val version: String,
val apiVersion: Int,
val registrationAllowed: Boolean,
val guestAllowed: Boolean,
val supportedServices: List<Int>,
val minClientVersion: InstanceMinClientVersion = InstanceMinClientVersion(),
val logoUrl: String? = null,
val bannerUrl: String? = null,
)
20 changes: 20 additions & 0 deletions src/main/kotlin/dev/typetype/server/routes/PublicMetadataRoutes.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package dev.typetype.server.routes

import dev.typetype.server.INSTANCE_CACHE_CONTROL
import dev.typetype.server.models.HealthResponse
import dev.typetype.server.models.InstanceResponse
import io.ktor.http.HttpHeaders
import io.ktor.server.application.call
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get

fun Route.publicMetadataRoutes(instanceProvider: suspend () -> InstanceResponse) {
get("/health") {
call.respond(HealthResponse())
}
get("/instance") {
call.response.headers.append(HttpHeaders.CacheControl, INSTANCE_CACHE_CONTROL)
call.respond(instanceProvider())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package dev.typetype.server.services

import dev.typetype.server.db.DatabaseFactory
import dev.typetype.server.db.tables.AdminSettingsTable
import dev.typetype.server.DEFAULT_INSTANCE_NAME
import dev.typetype.server.models.AdminSettingsItem
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.insert
Expand All @@ -12,34 +13,75 @@ private const val SETTINGS_ROW_ID = 1

class AdminSettingsService {

suspend fun get(): AdminSettingsItem = DatabaseFactory.query {
AdminSettingsTable.selectAll().singleOrNull()?.let {
AdminSettingsItem(
allowRegistration = it[AdminSettingsTable.allowRegistration],
allowGuest = it[AdminSettingsTable.allowGuest],
forceEmailVerification = it[AdminSettingsTable.forceEmailVerification],
)
} ?: AdminSettingsItem()
suspend fun get(): AdminSettingsItem {
cachedSettings?.let { return it }
val settings = DatabaseFactory.query {
AdminSettingsTable.selectAll().singleOrNull()?.let {
AdminSettingsItem(
name = it[AdminSettingsTable.name],
tagline = it[AdminSettingsTable.tagline],
logoUrl = it[AdminSettingsTable.logoUrl],
bannerUrl = it[AdminSettingsTable.bannerUrl],
minAndroidClientVersion = it[AdminSettingsTable.minAndroidClientVersion],
allowRegistration = it[AdminSettingsTable.allowRegistration],
allowGuest = it[AdminSettingsTable.allowGuest],
forceEmailVerification = it[AdminSettingsTable.forceEmailVerification],
).normalized()
} ?: AdminSettingsItem()
}
cachedSettings = settings
return settings
}

suspend fun upsert(item: AdminSettingsItem): AdminSettingsItem {
val settings = item.normalized()
DatabaseFactory.query {
val exists = AdminSettingsTable.selectAll().count() > 0
if (exists) {
AdminSettingsTable.update({ AdminSettingsTable.id eq SETTINGS_ROW_ID }) {
it[allowRegistration] = item.allowRegistration
it[allowGuest] = item.allowGuest
it[forceEmailVerification] = item.forceEmailVerification
it[name] = settings.name
it[tagline] = settings.tagline
it[logoUrl] = settings.logoUrl
it[bannerUrl] = settings.bannerUrl
it[minAndroidClientVersion] = settings.minAndroidClientVersion
it[allowRegistration] = settings.allowRegistration
it[allowGuest] = settings.allowGuest
it[forceEmailVerification] = settings.forceEmailVerification
}
} else {
AdminSettingsTable.insert {
it[id] = SETTINGS_ROW_ID
it[allowRegistration] = item.allowRegistration
it[allowGuest] = item.allowGuest
it[forceEmailVerification] = item.forceEmailVerification
it[name] = settings.name
it[tagline] = settings.tagline
it[logoUrl] = settings.logoUrl
it[bannerUrl] = settings.bannerUrl
it[minAndroidClientVersion] = settings.minAndroidClientVersion
it[allowRegistration] = settings.allowRegistration
it[allowGuest] = settings.allowGuest
it[forceEmailVerification] = settings.forceEmailVerification
}
}
}
return item
cachedSettings = settings
return settings
}

private fun AdminSettingsItem.normalized(): AdminSettingsItem = copy(
name = name.trim().takeIf { it.isNotEmpty() } ?: DEFAULT_INSTANCE_NAME,
tagline = tagline.normalizeOptionalText(),
logoUrl = logoUrl.normalizeOptionalText(),
bannerUrl = bannerUrl.normalizeOptionalText(),
minAndroidClientVersion = minAndroidClientVersion.normalizeOptionalText(),
)

private fun String?.normalizeOptionalText(): String? = this?.trim()?.takeIf { it.isNotEmpty() }

companion object {
@Volatile
private var cachedSettings: AdminSettingsItem? = null

fun clearCache() {
cachedSettings = null
}
}
}
33 changes: 33 additions & 0 deletions src/main/kotlin/dev/typetype/server/services/InstanceService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package dev.typetype.server.services

import dev.typetype.server.BuildInfo
import dev.typetype.server.DEFAULT_INSTANCE_NAME
import dev.typetype.server.INSTANCE_API_VERSION
import dev.typetype.server.models.InstanceMinClientVersion
import dev.typetype.server.models.InstanceResponse

class InstanceService(
private val authService: AuthService,
private val adminSettingsService: AdminSettingsService,
) {

suspend fun getInstance(): InstanceResponse {
val settings = adminSettingsService.get()
return InstanceResponse(
name = settings.name.normalizeName(),
tagline = settings.tagline.normalizeOptionalText(),
version = BuildInfo.VERSION,
apiVersion = INSTANCE_API_VERSION,
registrationAllowed = settings.allowRegistration || !authService.hasUsers(),
guestAllowed = settings.allowGuest,
supportedServices = VALID_SERVICE_IDS.sorted(),
minClientVersion = InstanceMinClientVersion(android = settings.minAndroidClientVersion.normalizeOptionalText()),
logoUrl = settings.logoUrl.normalizeOptionalText(),
bannerUrl = settings.bannerUrl.normalizeOptionalText(),
)
}

private fun String.normalizeName(): String = trim().takeIf { it.isNotEmpty() } ?: DEFAULT_INSTANCE_NAME

private fun String?.normalizeOptionalText(): String? = this?.trim()?.takeIf { it.isNotEmpty() }
}
35 changes: 35 additions & 0 deletions src/test/kotlin/dev/typetype/server/HealthRoutesTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package dev.typetype.server

import dev.typetype.server.routes.publicMetadataRoutes
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Test

class HealthRoutesTest {

@Test
fun `health returns ok without calling instance provider`() = testApplication {
var called = false
application {
install(ContentNegotiation) { json() }
routing {
publicMetadataRoutes {
called = true
error("instance provider should not be called")
}
}
}
val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("""{"status":"ok"}""", response.bodyAsText())
assertFalse(called)
}
}
Loading