From d295f22cd279fc6ff048329a2944c31248f915a6 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 10:15:58 +0200 Subject: [PATCH 01/15] chore: update kotlin and extractor --- build.gradle.kts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 13775a34..4b7d07a7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,15 +1,13 @@ plugins { - kotlin("jvm") version "2.3.21" - kotlin("plugin.serialization") version "2.3.21" + kotlin("jvm") version "2.4.0" + kotlin("plugin.serialization") version "2.4.0" id("io.ktor.plugin") version "3.5.0" id("jacoco") } apply(from = "gradle/openapi-validation.gradle.kts") - group = "dev.typetype" version = "0.0.1" - application { mainClass.set("dev.typetype.server.ApplicationKt") } @@ -27,11 +25,12 @@ dependencies { implementation("io.ktor:ktor-serialization-kotlinx-json-jvm") implementation("io.ktor:ktor-server-cors-jvm") implementation("io.ktor:ktor-server-compression-jvm") + implementation("io.ktor:ktor-server-websockets-jvm") implementation("io.ktor:ktor-server-status-pages-jvm") implementation("io.ktor:ktor-server-call-logging-jvm") implementation("io.ktor:ktor-server-rate-limit-jvm") implementation("ch.qos.logback:logback-classic:1.5.34") - implementation("com.github.InfinityLoop1308.PipePipeExtractor:extractor:3c8feae0548ba7e30bce92f881a1924121c3dc83") + implementation("com.github.InfinityLoop1308.PipePipeExtractor:extractor:2a4a333c0e64a96343d35981fd5e75fd122526c2") implementation("org.json:json:20250517") implementation("com.squareup.okhttp3:okhttp:5.3.2") implementation("io.lettuce:lettuce-core:7.6.0.RELEASE") From ac82619858c300af609ff06d7b0de467635b9b16 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 10:16:07 +0200 Subject: [PATCH 02/15] feat: expose remote login setting --- .../dev/typetype/server/db/DatabaseFactory.kt | 1 + .../db/DatabaseYoutubeRemoteLoginMigration.kt | 13 +++++++++++ .../server/db/tables/AdminSettingsTable.kt | 1 + .../server/models/AdminSettingsItem.kt | 1 + .../server/models/InstanceResponse.kt | 1 + .../server/services/AdminSettingsService.kt | 3 +++ .../server/services/InstanceService.kt | 2 ++ .../dev/typetype/server/InstanceRoutesTest.kt | 22 +++++++++---------- 8 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 src/main/kotlin/dev/typetype/server/db/DatabaseYoutubeRemoteLoginMigration.kt diff --git a/src/main/kotlin/dev/typetype/server/db/DatabaseFactory.kt b/src/main/kotlin/dev/typetype/server/db/DatabaseFactory.kt index d3d7971f..2e671e7e 100644 --- a/src/main/kotlin/dev/typetype/server/db/DatabaseFactory.kt +++ b/src/main/kotlin/dev/typetype/server/db/DatabaseFactory.kt @@ -99,6 +99,7 @@ object DatabaseFactory { exec("ALTER TABLE bug_reports ALTER COLUMN github_issue_url TYPE TEXT") DatabaseSessionAuthMigration.apply() DatabaseOidcMigration.apply() + DatabaseYoutubeRemoteLoginMigration.apply() exec("CREATE UNIQUE INDEX IF NOT EXISTS users_public_username_unique ON users (public_username)") DatabasePrimaryKeyMigrations.apply() DatabaseIndexMigrations.apply() diff --git a/src/main/kotlin/dev/typetype/server/db/DatabaseYoutubeRemoteLoginMigration.kt b/src/main/kotlin/dev/typetype/server/db/DatabaseYoutubeRemoteLoginMigration.kt new file mode 100644 index 00000000..ecc63328 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/db/DatabaseYoutubeRemoteLoginMigration.kt @@ -0,0 +1,13 @@ +package dev.typetype.server.db + +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager + +object DatabaseYoutubeRemoteLoginMigration { + fun apply() { + exec("ALTER TABLE admin_settings ADD COLUMN IF NOT EXISTS youtube_remote_login_enabled BOOLEAN NOT NULL DEFAULT false") + } + + private fun exec(sql: String) { + TransactionManager.current().exec(sql) + } +} diff --git a/src/main/kotlin/dev/typetype/server/db/tables/AdminSettingsTable.kt b/src/main/kotlin/dev/typetype/server/db/tables/AdminSettingsTable.kt index 52eb276f..b19f8fca 100644 --- a/src/main/kotlin/dev/typetype/server/db/tables/AdminSettingsTable.kt +++ b/src/main/kotlin/dev/typetype/server/db/tables/AdminSettingsTable.kt @@ -16,5 +16,6 @@ object AdminSettingsTable : Table("admin_settings") { val activeSessionsEnabled = bool("active_sessions_enabled").default(false) val localLoginEnabled = bool("local_login_enabled").default(true) val oidcAutoRedirect = bool("oidc_auto_redirect").default(false) + val youtubeRemoteLoginEnabled = bool("youtube_remote_login_enabled").default(false) override val primaryKey = PrimaryKey(id) } diff --git a/src/main/kotlin/dev/typetype/server/models/AdminSettingsItem.kt b/src/main/kotlin/dev/typetype/server/models/AdminSettingsItem.kt index 41ecaab6..2d2c712a 100644 --- a/src/main/kotlin/dev/typetype/server/models/AdminSettingsItem.kt +++ b/src/main/kotlin/dev/typetype/server/models/AdminSettingsItem.kt @@ -16,4 +16,5 @@ data class AdminSettingsItem( val activeSessionsEnabled: Boolean = false, val localLoginEnabled: Boolean = true, val oidcAutoRedirect: Boolean = false, + val youtubeRemoteLoginEnabled: Boolean = false, ) diff --git a/src/main/kotlin/dev/typetype/server/models/InstanceResponse.kt b/src/main/kotlin/dev/typetype/server/models/InstanceResponse.kt index 5c71f8fd..db145a36 100644 --- a/src/main/kotlin/dev/typetype/server/models/InstanceResponse.kt +++ b/src/main/kotlin/dev/typetype/server/models/InstanceResponse.kt @@ -18,4 +18,5 @@ data class InstanceResponse( val oidcEnabled: Boolean = false, val oidcProviderName: String? = null, val oidcAutoRedirect: Boolean = false, + val youtubeRemoteLoginEnabled: Boolean = false, ) diff --git a/src/main/kotlin/dev/typetype/server/services/AdminSettingsService.kt b/src/main/kotlin/dev/typetype/server/services/AdminSettingsService.kt index 15c22ecf..544141cc 100644 --- a/src/main/kotlin/dev/typetype/server/services/AdminSettingsService.kt +++ b/src/main/kotlin/dev/typetype/server/services/AdminSettingsService.kt @@ -29,6 +29,7 @@ class AdminSettingsService { activeSessionsEnabled = it[AdminSettingsTable.activeSessionsEnabled], localLoginEnabled = it[AdminSettingsTable.localLoginEnabled], oidcAutoRedirect = it[AdminSettingsTable.oidcAutoRedirect], + youtubeRemoteLoginEnabled = it[AdminSettingsTable.youtubeRemoteLoginEnabled], ).normalized() } ?: AdminSettingsItem() } @@ -53,6 +54,7 @@ class AdminSettingsService { it[activeSessionsEnabled] = settings.activeSessionsEnabled it[localLoginEnabled] = settings.localLoginEnabled it[oidcAutoRedirect] = settings.oidcAutoRedirect + it[youtubeRemoteLoginEnabled] = settings.youtubeRemoteLoginEnabled } } else { AdminSettingsTable.insert { @@ -68,6 +70,7 @@ class AdminSettingsService { it[activeSessionsEnabled] = settings.activeSessionsEnabled it[localLoginEnabled] = settings.localLoginEnabled it[oidcAutoRedirect] = settings.oidcAutoRedirect + it[youtubeRemoteLoginEnabled] = settings.youtubeRemoteLoginEnabled } } } diff --git a/src/main/kotlin/dev/typetype/server/services/InstanceService.kt b/src/main/kotlin/dev/typetype/server/services/InstanceService.kt index ddfd60ac..00a16a9c 100644 --- a/src/main/kotlin/dev/typetype/server/services/InstanceService.kt +++ b/src/main/kotlin/dev/typetype/server/services/InstanceService.kt @@ -11,6 +11,7 @@ class InstanceService( private val authService: AuthService, private val adminSettingsService: AdminSettingsService, private val oidcConfigProvider: () -> OidcPublicConfig = { OidcPublicConfig(enabled = false) }, + private val youtubeRemoteLoginAvailable: () -> Boolean = { false }, ) { suspend fun getInstance(): InstanceResponse { @@ -31,6 +32,7 @@ class InstanceService( oidcEnabled = oidc.enabled, oidcProviderName = oidc.providerName, oidcAutoRedirect = oidc.enabled && settings.oidcAutoRedirect, + youtubeRemoteLoginEnabled = settings.youtubeRemoteLoginEnabled && youtubeRemoteLoginAvailable(), ) } diff --git a/src/test/kotlin/dev/typetype/server/InstanceRoutesTest.kt b/src/test/kotlin/dev/typetype/server/InstanceRoutesTest.kt index 33791212..7220e2a9 100644 --- a/src/test/kotlin/dev/typetype/server/InstanceRoutesTest.kt +++ b/src/test/kotlin/dev/typetype/server/InstanceRoutesTest.kt @@ -38,6 +38,7 @@ class InstanceRoutesTest { private val adminSettings = AdminSettingsService() private val passwordReset = PasswordResetService() private val profile = ProfileService() + companion object { @BeforeAll @JvmStatic @@ -45,9 +46,7 @@ class InstanceRoutesTest { } @BeforeEach - fun clean() { - TestDatabase.truncateAll() - } + fun clean() { TestDatabase.truncateAll() } @Test fun `instance returns defaults and cache header`() = testApplication { @@ -68,10 +67,8 @@ class InstanceRoutesTest { assertEquals(true, root["guestAllowed"]?.jsonPrimitive?.boolean) assertEquals(true, root["localLoginEnabled"]?.jsonPrimitive?.boolean) assertEquals(false, root["oidcEnabled"]?.jsonPrimitive?.boolean) + assertEquals(false, root["youtubeRemoteLoginEnabled"]?.jsonPrimitive?.boolean) assertEquals(listOf(0, 3, 4, 5, 6), root["supportedServices"]?.jsonArray?.map { it.jsonPrimitive.int }) - assertEquals(null, root["logoUrl"]?.jsonPrimitive?.contentOrNull) - assertEquals(null, root["bannerUrl"]?.jsonPrimitive?.contentOrNull) - assertEquals(null, root["minClientVersion"]?.jsonObject?.get("android")?.jsonPrimitive?.contentOrNull) } @Test @@ -87,10 +84,16 @@ class InstanceRoutesTest { allowGuest = false, localLoginEnabled = false, oidcAutoRedirect = true, + youtubeRemoteLoginEnabled = true, ) ) val auth = AuthService.fixed(TEST_USER_ID, hasUsers = true) - val instanceService = InstanceService(auth, adminSettings) { OidcPublicConfig(enabled = true, providerName = "Keycloak") } + val instanceService = InstanceService( + auth, + adminSettings, + youtubeRemoteLoginAvailable = { true }, + oidcConfigProvider = { OidcPublicConfig(enabled = true, providerName = "Keycloak") }, + ) application { install(ContentNegotiation) { json() } routing { @@ -101,16 +104,13 @@ class InstanceRoutesTest { val response = client.get("/instance") val root = Json.parseToJsonElement(response.bodyAsText()).jsonObject assertEquals("Custom Instance", root["name"]?.jsonPrimitive?.contentOrNull) - assertEquals("Privacy-respecting video platform", root["tagline"]?.jsonPrimitive?.contentOrNull) - assertEquals("https://cdn.example.com/typetype/logo.png", root["logoUrl"]?.jsonPrimitive?.contentOrNull) - assertEquals("https://cdn.example.com/typetype/banner.jpg", root["bannerUrl"]?.jsonPrimitive?.contentOrNull) - assertEquals("0.1.0", root["minClientVersion"]?.jsonObject?.get("android")?.jsonPrimitive?.contentOrNull) assertEquals(false, root["registrationAllowed"]?.jsonPrimitive?.boolean) assertEquals(false, root["guestAllowed"]?.jsonPrimitive?.boolean) assertEquals(false, root["localLoginEnabled"]?.jsonPrimitive?.boolean) assertEquals(true, root["oidcEnabled"]?.jsonPrimitive?.boolean) assertEquals("Keycloak", root["oidcProviderName"]?.jsonPrimitive?.contentOrNull) assertEquals(true, root["oidcAutoRedirect"]?.jsonPrimitive?.boolean) + assertEquals(true, root["youtubeRemoteLoginEnabled"]?.jsonPrimitive?.boolean) val register = client.post("/auth/register") { contentType(ContentType.Application.Json) setBody("""{"email":"new@test.local","password":"secret","name":"New"}""") From 02b17369a6bb99a22a501e7036958f1a3036d27b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 10:16:16 +0200 Subject: [PATCH 03/15] feat: model remote login sessions --- .../YoutubeRemoteBrowserCompleteRequest.kt | 13 +++ .../YoutubeRemoteBrowserStartRequest.kt | 8 ++ .../YoutubeRemoteBrowserStartResponse.kt | 10 ++ .../YoutubeRemoteBrowserCompleteResult.kt | 9 ++ .../services/YoutubeRemoteBrowserConfig.kt | 45 +++++++++ .../YoutubeRemoteBrowserReserveResult.kt | 7 ++ .../services/YoutubeRemoteBrowserSession.kt | 9 ++ .../YoutubeRemoteBrowserSessionStore.kt | 91 +++++++++++++++++++ .../YoutubeRemoteBrowserStartResult.kt | 12 +++ 9 files changed, 204 insertions(+) create mode 100644 src/main/kotlin/dev/typetype/server/models/YoutubeRemoteBrowserCompleteRequest.kt create mode 100644 src/main/kotlin/dev/typetype/server/models/YoutubeRemoteBrowserStartRequest.kt create mode 100644 src/main/kotlin/dev/typetype/server/models/YoutubeRemoteBrowserStartResponse.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserCompleteResult.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserConfig.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserReserveResult.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserSession.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserSessionStore.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserStartResult.kt diff --git a/src/main/kotlin/dev/typetype/server/models/YoutubeRemoteBrowserCompleteRequest.kt b/src/main/kotlin/dev/typetype/server/models/YoutubeRemoteBrowserCompleteRequest.kt new file mode 100644 index 00000000..42534723 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/YoutubeRemoteBrowserCompleteRequest.kt @@ -0,0 +1,13 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class YoutubeRemoteBrowserCompleteRequest( + val sessionId: String, + val tokenSessionId: String, + val status: String, + val cookies: String, + val poToken: String, + val capturedAt: Long, +) diff --git a/src/main/kotlin/dev/typetype/server/models/YoutubeRemoteBrowserStartRequest.kt b/src/main/kotlin/dev/typetype/server/models/YoutubeRemoteBrowserStartRequest.kt new file mode 100644 index 00000000..01e2d45a --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/YoutubeRemoteBrowserStartRequest.kt @@ -0,0 +1,8 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class YoutubeRemoteBrowserStartRequest( + val returnTo: String? = null, +) diff --git a/src/main/kotlin/dev/typetype/server/models/YoutubeRemoteBrowserStartResponse.kt b/src/main/kotlin/dev/typetype/server/models/YoutubeRemoteBrowserStartResponse.kt new file mode 100644 index 00000000..a6375101 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/YoutubeRemoteBrowserStartResponse.kt @@ -0,0 +1,10 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class YoutubeRemoteBrowserStartResponse( + val sessionId: String, + val wsUrl: String, + val expiresAt: Long, +) diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserCompleteResult.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserCompleteResult.kt new file mode 100644 index 00000000..8309dd47 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserCompleteResult.kt @@ -0,0 +1,9 @@ +package dev.typetype.server.services + +sealed interface YoutubeRemoteBrowserCompleteResult { + data object Completed : YoutubeRemoteBrowserCompleteResult + data object Unauthorized : YoutubeRemoteBrowserCompleteResult + data object NotFound : YoutubeRemoteBrowserCompleteResult + data object InvalidPayload : YoutubeRemoteBrowserCompleteResult + data object InvalidCredentials : YoutubeRemoteBrowserCompleteResult +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserConfig.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserConfig.kt new file mode 100644 index 00000000..190c9210 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserConfig.kt @@ -0,0 +1,45 @@ +package dev.typetype.server.services + +data class YoutubeRemoteBrowserConfig( + val serviceUrl: String, + val callbackBaseUrl: String, + val internalToken: String?, + val ttlMs: Long, + val maxGlobalSessions: Int, + val maxFrameBytes: Int, + val maxInputBytes: Int, + val outboundQueueSize: Int, +) { + val callbackUrl: String = + "${callbackBaseUrl.trimEnd('/')}/internal/youtube-remote-login/callback" + val isConfigured: Boolean = !internalToken.isNullOrBlank() + + companion object { + private const val DEFAULT_TTL_MS = 8 * 60 * 1000L + private const val DEFAULT_MAX_GLOBAL_SESSIONS = 2 + private const val DEFAULT_MAX_FRAME_BYTES = 512 * 1024 + private const val DEFAULT_MAX_INPUT_BYTES = 4096 + private const val DEFAULT_OUTBOUND_QUEUE_SIZE = 2 + + fun fromEnvironment(tokenServiceUrl: String): YoutubeRemoteBrowserConfig = + YoutubeRemoteBrowserConfig( + serviceUrl = envText("YOUTUBE_REMOTE_LOGIN_SERVICE_URL") ?: tokenServiceUrl, + callbackBaseUrl = envText("YOUTUBE_REMOTE_LOGIN_CALLBACK_BASE_URL") ?: "http://localhost:8080", + internalToken = envText("YOUTUBE_REMOTE_LOGIN_INTERNAL_TOKEN"), + ttlMs = envLong("YOUTUBE_REMOTE_LOGIN_TTL_MS", DEFAULT_TTL_MS).coerceIn(60_000L, 10 * 60_000L), + maxGlobalSessions = envInt("YOUTUBE_REMOTE_LOGIN_MAX_SESSIONS", DEFAULT_MAX_GLOBAL_SESSIONS).coerceIn(1, 8), + maxFrameBytes = envInt("YOUTUBE_REMOTE_LOGIN_MAX_FRAME_BYTES", DEFAULT_MAX_FRAME_BYTES).coerceIn(64 * 1024, 2 * 1024 * 1024), + maxInputBytes = envInt("YOUTUBE_REMOTE_LOGIN_MAX_INPUT_BYTES", DEFAULT_MAX_INPUT_BYTES).coerceIn(512, 16 * 1024), + outboundQueueSize = envInt("YOUTUBE_REMOTE_LOGIN_OUTBOUND_QUEUE_SIZE", DEFAULT_OUTBOUND_QUEUE_SIZE).coerceIn(1, 8), + ) + + private fun envText(name: String): String? = + System.getenv(name)?.trim()?.takeIf { it.isNotEmpty() } + + private fun envLong(name: String, fallback: Long): Long = + envText(name)?.toLongOrNull() ?: fallback + + private fun envInt(name: String, fallback: Int): Int = + envText(name)?.toIntOrNull() ?: fallback + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserReserveResult.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserReserveResult.kt new file mode 100644 index 00000000..9c3e8827 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserReserveResult.kt @@ -0,0 +1,7 @@ +package dev.typetype.server.services + +sealed interface YoutubeRemoteBrowserReserveResult { + data class Reserved(val session: YoutubeRemoteBrowserSession, val wsToken: String) : YoutubeRemoteBrowserReserveResult + data object AlreadyActive : YoutubeRemoteBrowserReserveResult + data object CapacityReached : YoutubeRemoteBrowserReserveResult +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserSession.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserSession.kt new file mode 100644 index 00000000..0a410c0d --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserSession.kt @@ -0,0 +1,9 @@ +package dev.typetype.server.services + +data class YoutubeRemoteBrowserSession( + val sessionId: String, + val userId: String, + val wsTokenHash: String, + val tokenSessionId: String?, + val expiresAt: Long, +) diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserSessionStore.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserSessionStore.kt new file mode 100644 index 00000000..6b5d09d1 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserSessionStore.kt @@ -0,0 +1,91 @@ +package dev.typetype.server.services + +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 +import java.util.UUID + +class YoutubeRemoteBrowserSessionStore( + private val nowMillis: () -> Long = System::currentTimeMillis, + private val random: SecureRandom = SecureRandom(), +) { + private val sessions = mutableMapOf() + + fun reserve(userId: String, config: YoutubeRemoteBrowserConfig): YoutubeRemoteBrowserReserveResult = + synchronized(sessions) { + cleanupExpiredLocked() + if (sessions.values.any { it.userId == userId }) return@synchronized YoutubeRemoteBrowserReserveResult.AlreadyActive + if (sessions.size >= config.maxGlobalSessions) return@synchronized YoutubeRemoteBrowserReserveResult.CapacityReached + val token = randomToken() + val session = YoutubeRemoteBrowserSession( + sessionId = UUID.randomUUID().toString(), + userId = userId, + wsTokenHash = hash(token), + tokenSessionId = null, + expiresAt = nowMillis() + config.ttlMs, + ) + sessions[session.sessionId] = session + YoutubeRemoteBrowserReserveResult.Reserved(session, token) + } + + fun attachTokenSession(sessionId: String, tokenSessionId: String, expiresAt: Long): YoutubeRemoteBrowserSession? = + synchronized(sessions) { + val session = sessions[sessionId]?.takeIf { it.expiresAt > nowMillis() } ?: return@synchronized null + val attached = session.copy(tokenSessionId = tokenSessionId, expiresAt = minOf(session.expiresAt, expiresAt)) + sessions[sessionId] = attached + attached + } + + fun authenticateWebSocket(sessionId: String, token: String): YoutubeRemoteBrowserSession? = + synchronized(sessions) { + cleanupExpiredLocked() + sessions[sessionId]?.takeIf { constantEquals(it.wsTokenHash, hash(token)) } + } + + fun cancel(userId: String, sessionId: String): String? = + synchronized(sessions) { + val session = sessions[sessionId]?.takeIf { it.userId == userId } ?: return@synchronized null + sessions.remove(sessionId) + session.tokenSessionId + } + + fun remove(sessionId: String): String? = + synchronized(sessions) { sessions.remove(sessionId)?.tokenSessionId } + + fun complete(sessionId: String, tokenSessionId: String): YoutubeRemoteBrowserSession? = + synchronized(sessions) { + val session = sessions[sessionId]?.takeIf { + it.tokenSessionId == tokenSessionId && it.expiresAt > nowMillis() + } ?: return@synchronized null + sessions.remove(sessionId) + session + } + + fun activeCount(): Int = synchronized(sessions) { + cleanupExpiredLocked() + sessions.size + } + + private fun cleanupExpiredLocked() { + val now = nowMillis() + sessions.entries.removeIf { it.value.expiresAt <= now } + } + + private fun randomToken(): String { + val bytes = ByteArray(32) + random.nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } + + private fun hash(value: String): String = + Base64.getUrlEncoder().withoutPadding().encodeToString( + MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) + ) + + private fun constantEquals(left: String, right: String): Boolean { + if (left.length != right.length) return false + var diff = 0 + left.indices.forEach { diff = diff or (left[it].code xor right[it].code) } + return diff == 0 + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserStartResult.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserStartResult.kt new file mode 100644 index 00000000..e9256726 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserStartResult.kt @@ -0,0 +1,12 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.YoutubeRemoteBrowserStartResponse + +sealed interface YoutubeRemoteBrowserStartResult { + data class Started(val response: YoutubeRemoteBrowserStartResponse) : YoutubeRemoteBrowserStartResult + data object Disabled : YoutubeRemoteBrowserStartResult + data object Misconfigured : YoutubeRemoteBrowserStartResult + data object AlreadyActive : YoutubeRemoteBrowserStartResult + data object CapacityReached : YoutubeRemoteBrowserStartResult + data object TokenUnavailable : YoutubeRemoteBrowserStartResult +} From c76f1acaccc94767d3f089b0ec7e9fd7c33f7c9b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 10:16:26 +0200 Subject: [PATCH 04/15] feat: bridge remote login token service --- .../OkHttpYoutubeRemoteBrowserClient.kt | 118 ++++++++++++++++++ .../services/YoutubeRemoteBrowserClient.kt | 19 +++ .../YoutubeRemoteBrowserMessageGuard.kt | 77 ++++++++++++ .../YoutubeRemoteBrowserTokenStartRequest.kt | 11 ++ .../YoutubeRemoteBrowserTokenStartResponse.kt | 9 ++ 5 files changed, 234 insertions(+) create mode 100644 src/main/kotlin/dev/typetype/server/services/OkHttpYoutubeRemoteBrowserClient.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserClient.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserMessageGuard.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserTokenStartRequest.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserTokenStartResponse.kt diff --git a/src/main/kotlin/dev/typetype/server/services/OkHttpYoutubeRemoteBrowserClient.kt b/src/main/kotlin/dev/typetype/server/services/OkHttpYoutubeRemoteBrowserClient.kt new file mode 100644 index 00000000..4a321af8 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/OkHttpYoutubeRemoteBrowserClient.kt @@ -0,0 +1,118 @@ +package dev.typetype.server.services + +import io.ktor.server.websocket.DefaultWebSocketServerSession +import io.ktor.websocket.Frame +import io.ktor.websocket.readText +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString + +class OkHttpYoutubeRemoteBrowserClient( + private val serviceUrl: String, + private val client: OkHttpClient = OkHttpClient(), +) : YoutubeRemoteBrowserClient { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + override suspend fun start(request: YoutubeRemoteBrowserTokenStartRequest, internalToken: String): YoutubeRemoteBrowserTokenStartResponse? = + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + val httpRequest = Request.Builder() + .url("${serviceUrl.trimEnd('/')}/youtube-remote-login/start") + .header(INTERNAL_HEADER, internalToken) + .post(json.encodeToString(request).toRequestBody(JSON_MEDIA_TYPE)) + .build() + runCatching { + client.newCall(httpRequest).execute().use(::decodeStartResponse) + }.getOrNull() + } + + override suspend fun cancel(tokenSessionId: String, internalToken: String): Boolean = + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + val request = Request.Builder() + .url("${serviceUrl.trimEnd('/')}/youtube-remote-login/$tokenSessionId") + .header(INTERNAL_HEADER, internalToken) + .delete() + .build() + runCatching { client.newCall(request).execute().use { it.isSuccessful } }.getOrDefault(false) + } + + override suspend fun bridge( + serverSession: DefaultWebSocketServerSession, + tokenSessionId: String, + internalToken: String, + config: YoutubeRemoteBrowserConfig, + ): Unit = coroutineScope { + val done = CompletableDeferred() + val outbound = Channel(config.outboundQueueSize, BufferOverflow.DROP_OLDEST) + val socket = client.newWebSocket(tokenWebSocketRequest(tokenSessionId, internalToken), listener(outbound, done, config)) + val outboundJob = launch { for (frame in outbound) serverSession.send(frame) } + val inboundJob = launch { + for (frame in serverSession.incoming) { + if (frame is Frame.Text) { + YoutubeRemoteBrowserMessageGuard.frontendText(frame.readText(), config.maxInputBytes)?.let(socket::send) + } + if (frame is Frame.Close) done.complete(Unit) + } + } + outboundJob.invokeOnCompletion { done.complete(Unit) } + inboundJob.invokeOnCompletion { done.complete(Unit) } + done.await() + socket.close(NORMAL_CLOSE, null) + outbound.close() + outboundJob.cancel() + inboundJob.cancel() + } + + private fun decodeStartResponse(response: Response): YoutubeRemoteBrowserTokenStartResponse? { + if (!response.isSuccessful) return null + return json.decodeFromString(response.body.string()) + } + + private fun tokenWebSocketRequest(tokenSessionId: String, internalToken: String): Request = + Request.Builder() + .url("${webSocketBaseUrl()}/youtube-remote-login/$tokenSessionId") + .header(INTERNAL_HEADER, internalToken) + .build() + + private fun listener( + outbound: Channel, + done: CompletableDeferred, + config: YoutubeRemoteBrowserConfig, + ): WebSocketListener = object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, text: String) { + YoutubeRemoteBrowserMessageGuard.tokenText(text)?.let { outbound.trySend(Frame.Text(it)) } + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + if (bytes.size <= config.maxFrameBytes) outbound.trySend(Frame.Binary(true, bytes.toByteArray())) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + done.complete(Unit) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + done.complete(Unit) + } + } + + private fun webSocketBaseUrl(): String = + serviceUrl.trimEnd('/').replaceFirst("https://", "wss://").replaceFirst("http://", "ws://") + + companion object { + private const val INTERNAL_HEADER = "X-Internal-Token" + private const val NORMAL_CLOSE = 1000 + private val JSON_MEDIA_TYPE = "application/json".toMediaType() + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserClient.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserClient.kt new file mode 100644 index 00000000..f9a88f22 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserClient.kt @@ -0,0 +1,19 @@ +package dev.typetype.server.services + +import io.ktor.server.websocket.DefaultWebSocketServerSession + +interface YoutubeRemoteBrowserClient { + suspend fun start( + request: YoutubeRemoteBrowserTokenStartRequest, + internalToken: String, + ): YoutubeRemoteBrowserTokenStartResponse? + + suspend fun cancel(tokenSessionId: String, internalToken: String): Boolean + + suspend fun bridge( + serverSession: DefaultWebSocketServerSession, + tokenSessionId: String, + internalToken: String, + config: YoutubeRemoteBrowserConfig, + ) +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserMessageGuard.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserMessageGuard.kt new file mode 100644 index 00000000..3c9e475c --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserMessageGuard.kt @@ -0,0 +1,77 @@ +package dev.typetype.server.services + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object YoutubeRemoteBrowserMessageGuard { + private val json = Json { ignoreUnknownKeys = true } + private val clientTypes = setOf("resize", "pointer", "wheel", "key", "text", "cancel") + private val tokenTypes = setOf("status", "error") + private val phases = setOf("opening", "awaiting_login", "capturing_session", "connected") + private val pointerEvents = setOf("down", "up", "move") + private val keyEvents = setOf("down", "up") + private val buttons = setOf("left", "middle", "right") + + fun frontendText(text: String, maxBytes: Int): String? { + if (text.toByteArray(Charsets.UTF_8).size > maxBytes) return null + val obj = parseObject(text) ?: return null + return if (isValidClientMessage(obj)) text else null + } + + fun tokenText(text: String): String? { + if (text.toByteArray(Charsets.UTF_8).size > 4096) return null + val obj = parseObject(text) ?: return null + return if (isValidTokenMessage(obj)) text else null + } + + private fun isValidClientMessage(obj: JsonObject): Boolean = + when (obj.string("type")?.takeIf { it in clientTypes }) { + "resize" -> obj.int("width").inRange(320..1920) && obj.int("height").inRange(240..1080) + "pointer" -> validPointer(obj) + "wheel" -> obj.double("deltaX").inDeltaRange() && obj.double("deltaY").inDeltaRange() + "key" -> validKey(obj) + "text" -> (obj.string("value")?.length ?: Int.MAX_VALUE) <= 2048 + "cancel" -> true + else -> false + } + + private fun isValidTokenMessage(obj: JsonObject): Boolean = + when (obj.string("type")?.takeIf { it in tokenTypes }) { + "status" -> obj.string("phase")?.let { it in phases } == true + "error" -> (obj.string("message")?.length ?: Int.MAX_VALUE) <= 200 + else -> false + } + + private fun validPointer(obj: JsonObject): Boolean = + obj.string("event")?.let { it in pointerEvents } == true && + obj.double("x").inCoordinateRange() && + obj.double("y").inCoordinateRange() && + obj.string("button")?.let { it in buttons } == true + + private fun validKey(obj: JsonObject): Boolean = + obj.string("event")?.let { it in keyEvents } == true && + (obj.string("key")?.length ?: Int.MAX_VALUE) <= 64 && + (obj.string("code")?.length ?: 0) <= 64 && + ((obj["modifiers"] as? JsonArray)?.size ?: 0) <= 8 + + private fun Double?.inDeltaRange(): Boolean = this != null && this in -5000.0..5000.0 + + private fun Double?.inCoordinateRange(): Boolean = this != null && this in 0.0..4096.0 + + private fun Int?.inRange(range: IntRange): Boolean = this != null && this in range + + private fun parseObject(text: String): JsonObject? = + runCatching { json.parseToJsonElement(text).jsonObject }.getOrNull() + + private fun JsonObject.string(name: String): String? = this[name]?.jsonPrimitive?.contentOrNull + + private fun JsonObject.int(name: String): Int? = this[name]?.jsonPrimitive?.intOrNull + + private fun JsonObject.double(name: String): Double? = this[name]?.jsonPrimitive?.doubleOrNull +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserTokenStartRequest.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserTokenStartRequest.kt new file mode 100644 index 00000000..47c9c05c --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserTokenStartRequest.kt @@ -0,0 +1,11 @@ +package dev.typetype.server.services + +import kotlinx.serialization.Serializable + +@Serializable +data class YoutubeRemoteBrowserTokenStartRequest( + val serverSessionId: String, + val userId: String, + val callbackUrl: String, + val ttlMs: Long, +) diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserTokenStartResponse.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserTokenStartResponse.kt new file mode 100644 index 00000000..67f8bcff --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserTokenStartResponse.kt @@ -0,0 +1,9 @@ +package dev.typetype.server.services + +import kotlinx.serialization.Serializable + +@Serializable +data class YoutubeRemoteBrowserTokenStartResponse( + val sessionId: String, + val expiresAt: Long, +) From 0fa9d72a686e05545c399fd65c0ab906df6945f0 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 10:16:40 +0200 Subject: [PATCH 05/15] feat: store remote login credentials --- .../services/YoutubeRemoteBrowserService.kt | 108 ++++++++++++++++++ .../server/services/YoutubeSessionService.kt | 15 +++ .../server/services/YoutubeSessionStore.kt | 5 + 3 files changed, 128 insertions(+) create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserService.kt diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserService.kt new file mode 100644 index 00000000..26b8aa9b --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeRemoteBrowserService.kt @@ -0,0 +1,108 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.YoutubeRemoteBrowserCompleteRequest +import dev.typetype.server.models.YoutubeRemoteBrowserStartResponse +import io.ktor.server.websocket.DefaultWebSocketServerSession +import io.ktor.websocket.CloseReason +import io.ktor.websocket.close +import kotlinx.coroutines.withTimeoutOrNull + +class YoutubeRemoteBrowserService( + private val config: YoutubeRemoteBrowserConfig, + private val adminSettingsService: AdminSettingsService, + private val youtubeSessionService: YoutubeSessionService, + private val client: YoutubeRemoteBrowserClient, + private val sessions: YoutubeRemoteBrowserSessionStore = YoutubeRemoteBrowserSessionStore(), +) { + suspend fun start(userId: String, returnTo: String?): YoutubeRemoteBrowserStartResult { + if (!adminSettingsService.get().youtubeRemoteLoginEnabled) { + return YoutubeRemoteBrowserStartResult.Disabled + } + val internalToken = config.internalToken ?: return YoutubeRemoteBrowserStartResult.Misconfigured + return when (val reserved = sessions.reserve(userId, config)) { + YoutubeRemoteBrowserReserveResult.AlreadyActive -> YoutubeRemoteBrowserStartResult.AlreadyActive + YoutubeRemoteBrowserReserveResult.CapacityReached -> YoutubeRemoteBrowserStartResult.CapacityReached + is YoutubeRemoteBrowserReserveResult.Reserved -> startTokenSession(reserved, userId, internalToken, returnTo) + } + } + + suspend fun cancel(userId: String, sessionId: String): Boolean { + val tokenSessionId = sessions.cancel(userId, sessionId) ?: return false + val internalToken = config.internalToken ?: return true + client.cancel(tokenSessionId, internalToken) + return true + } + + suspend fun complete( + request: YoutubeRemoteBrowserCompleteRequest, + internalToken: String?, + ): YoutubeRemoteBrowserCompleteResult { + if (!isInternalAuthorized(internalToken)) return YoutubeRemoteBrowserCompleteResult.Unauthorized + if (request.status != "completed") return YoutubeRemoteBrowserCompleteResult.InvalidPayload + val session = sessions.complete(request.sessionId, request.tokenSessionId) + ?: return YoutubeRemoteBrowserCompleteResult.NotFound + return when (youtubeSessionService.completeRemote(session.userId, request.cookies, request.poToken)) { + YoutubeSessionCompleteResult.Completed -> YoutubeRemoteBrowserCompleteResult.Completed + else -> YoutubeRemoteBrowserCompleteResult.InvalidCredentials + } + } + + suspend fun bridge(sessionId: String, wsToken: String?, serverSession: DefaultWebSocketServerSession): Unit { + val session = wsToken?.let { sessions.authenticateWebSocket(sessionId, it) } + val tokenSessionId = session?.tokenSessionId + val internalToken = config.internalToken + if (session == null || tokenSessionId == null || internalToken == null) { + serverSession.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Unauthorized")) + return + } + try { + client.bridge(serverSession, tokenSessionId, internalToken, config) + } finally { + cancel(session.userId, session.sessionId) + } + } + + private suspend fun startTokenSession( + reserved: YoutubeRemoteBrowserReserveResult.Reserved, + userId: String, + internalToken: String, + returnTo: String?, + ): YoutubeRemoteBrowserStartResult { + if ((returnTo?.length ?: 0) > MAX_RETURN_TO_LENGTH) { + sessions.remove(reserved.session.sessionId) + return YoutubeRemoteBrowserStartResult.TokenUnavailable + } + val tokenResponse = withTimeoutOrNull(START_TIMEOUT_MS) { + client.start( + YoutubeRemoteBrowserTokenStartRequest( + serverSessionId = reserved.session.sessionId, + userId = userId, + callbackUrl = config.callbackUrl, + ttlMs = config.ttlMs, + ), + internalToken, + ) + } + if (tokenResponse == null) { + sessions.remove(reserved.session.sessionId) + return YoutubeRemoteBrowserStartResult.TokenUnavailable + } + val attached = sessions.attachTokenSession(reserved.session.sessionId, tokenResponse.sessionId, tokenResponse.expiresAt) + ?: return YoutubeRemoteBrowserStartResult.TokenUnavailable + return YoutubeRemoteBrowserStartResult.Started( + YoutubeRemoteBrowserStartResponse( + sessionId = attached.sessionId, + wsUrl = "/youtube-session/browser/${attached.sessionId}?token=${reserved.wsToken}", + expiresAt = attached.expiresAt, + ) + ) + } + + private fun isInternalAuthorized(value: String?): Boolean = + value != null && value == config.internalToken + + companion object { + private const val START_TIMEOUT_MS = 10_000L + private const val MAX_RETURN_TO_LENGTH = 2048 + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionService.kt index eec3fd10..2b9161af 100644 --- a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionService.kt +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionService.kt @@ -27,6 +27,21 @@ class YoutubeSessionService( ) } + suspend fun completeRemote(userId: String, rawCookies: String, rawPoToken: String): YoutubeSessionCompleteResult { + val cookies = YoutubeSessionCookieNormalizer.normalize(rawCookies) + ?: return YoutubeSessionCompleteResult.InvalidCredentials + val poToken = rawPoToken.trim() + if (!YoutubeSessionCredentialValidator.isValid(cookies, poToken)) { + return YoutubeSessionCompleteResult.InvalidCredentials + } + store.completeForUser( + userId = userId, + encryptedCookies = crypto.encrypt(cookies), + encryptedPoToken = crypto.encrypt(poToken), + ) + return YoutubeSessionCompleteResult.Completed + } + suspend fun status(userId: String): YoutubeSessionStatusResponse = store.status(userId) suspend fun delete(userId: String): Boolean = store.delete(userId) diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStore.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStore.kt index f88529b6..2d424534 100644 --- a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStore.kt +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStore.kt @@ -45,6 +45,11 @@ class YoutubeSessionStore( YoutubeSessionsTable.deleteWhere { YoutubeSessionsTable.userId eq userId } > 0 } + suspend fun completeForUser(userId: String, encryptedCookies: String, encryptedPoToken: String): Unit = + DatabaseFactory.query { + upsertSession(userId, encryptedCookies, encryptedPoToken, nowMillis()) + } + suspend fun connectedEncrypted(userId: String): Pair? = DatabaseFactory.query { YoutubeSessionsTable.selectAll() .where { YoutubeSessionsTable.userId eq userId } From fa5b714ae6fb85f825b7f8f8dccb4b75ef8880e7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 10:16:58 +0200 Subject: [PATCH 06/15] feat: wire remote browser routes --- .env.example | 5 + .../kotlin/dev/typetype/server/Application.kt | 97 ++++++----------- .../dev/typetype/server/ApplicationRoutes.kt | 100 ++++++++++++++++++ .../kotlin/dev/typetype/server/Plugins.kt | 7 ++ .../routes/YoutubeRemoteBrowserRoutes.kt | 70 ++++++++++++ 5 files changed, 216 insertions(+), 63 deletions(-) create mode 100644 src/main/kotlin/dev/typetype/server/ApplicationRoutes.kt create mode 100644 src/main/kotlin/dev/typetype/server/routes/YoutubeRemoteBrowserRoutes.kt diff --git a/.env.example b/.env.example index 0335dbee..07721e95 100644 --- a/.env.example +++ b/.env.example @@ -5,5 +5,10 @@ DATABASE_PASSWORD=typetype DRAGONFLY_URL=redis://dragonfly:6379 DOWNLOADER_SERVICE_URL=http://typetype-downloader:18093 YOUTUBE_SESSION_ENCRYPTION_KEY=replace-with-at-least-32-random-characters +YOUTUBE_REMOTE_LOGIN_SERVICE_URL=http://typetype-token:8081 +YOUTUBE_REMOTE_LOGIN_CALLBACK_BASE_URL=http://typetype-server:8080 +YOUTUBE_REMOTE_LOGIN_INTERNAL_TOKEN=replace-with-shared-internal-token +YOUTUBE_REMOTE_LOGIN_TTL_MS=480000 +YOUTUBE_REMOTE_LOGIN_MAX_SESSIONS=2 ALLOWED_ORIGINS=http://localhost:5173 diff --git a/src/main/kotlin/dev/typetype/server/Application.kt b/src/main/kotlin/dev/typetype/server/Application.kt index 33fe79b7..18c5637e 100644 --- a/src/main/kotlin/dev/typetype/server/Application.kt +++ b/src/main/kotlin/dev/typetype/server/Application.kt @@ -2,30 +2,6 @@ package dev.typetype.server import dev.typetype.server.cache.DragonflyService import dev.typetype.server.db.DatabaseFactory import dev.typetype.server.downloader.OkHttpDownloader -import dev.typetype.server.routes.avatarRoutes -import dev.typetype.server.routes.bulletCommentRoutes -import dev.typetype.server.routes.channelRoutes -import dev.typetype.server.routes.commentRoutes -import dev.typetype.server.routes.downloaderGatewayRoutes -import dev.typetype.server.routes.internalObservabilityRoutes -import dev.typetype.server.routes.manifestRoutes -import dev.typetype.server.routes.nicoVideoProxyRoutes -import dev.typetype.server.routes.oidcAuthRoutes -import dev.typetype.server.routes.podcastRoutes -import dev.typetype.server.routes.proxyRoutes -import dev.typetype.server.routes.publicPlaylistRoutes -import dev.typetype.server.routes.storyboardProxyRoutes -import dev.typetype.server.routes.searchRoutes -import dev.typetype.server.routes.streamRoutes -import dev.typetype.server.routes.suggestionRoutes -import dev.typetype.server.routes.adminSessionRoutes -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.sessionActivityRoutes -import dev.typetype.server.routes.userDataRoutes import dev.typetype.server.services.ActiveSessionService import dev.typetype.server.services.AuthService import dev.typetype.server.services.AdminSettingsService @@ -40,11 +16,12 @@ import dev.typetype.server.services.InstanceService import dev.typetype.server.services.InternalHealthService import dev.typetype.server.services.OidcAuthService import dev.typetype.server.services.OidcConfigLoader +import dev.typetype.server.services.OkHttpYoutubeRemoteBrowserClient import dev.typetype.server.services.UserAdminService +import dev.typetype.server.services.YoutubeRemoteBrowserConfig +import dev.typetype.server.services.YoutubeRemoteBrowserService import io.ktor.server.application.Application import io.ktor.server.netty.EngineMain -import io.ktor.server.plugins.ratelimit.rateLimit -import io.ktor.server.routing.routing import org.schabi.newpipe.extractor.NewPipe import java.util.UUID @@ -67,53 +44,47 @@ fun Application.module() { val gitHubIssueService = GitHubIssueService() val adminSettingsService = AdminSettingsService() val activeSessionService = ActiveSessionService(adminSettingsService) - val instanceService = InstanceService(authService, adminSettingsService, oidcAuthService::publicConfig) val restoreService = PipePipeBackupImporterService() val cacheUrl = System.getenv("DRAGONFLY_URL") ?: "redis://localhost:6379" val subtitleServiceUrl = System.getenv("SUBTITLE_SERVICE_URL") ?: "http://typetype-token:8081" + val youtubeRemoteBrowserConfig = YoutubeRemoteBrowserConfig.fromEnvironment(subtitleServiceUrl) + val instanceService = InstanceService( + authService, + adminSettingsService, + youtubeRemoteLoginAvailable = { youtubeRemoteBrowserConfig.isConfigured }, + oidcConfigProvider = oidcAuthService::publicConfig, + ) val downloaderServiceUrl = System.getenv("DOWNLOADER_SERVICE_URL") ?: "http://typetype-downloader:18093" val youtubeSessionEncryptionKey = System.getenv("YOUTUBE_SESSION_ENCRYPTION_KEY") ?: error("YOUTUBE_SESSION_ENCRYPTION_KEY is required") val cache = DragonflyService(cacheUrl) val svc = ServiceRegistry(cache, subtitleServiceUrl, youtubeSessionEncryptionKey) + val youtubeRemoteBrowserService = YoutubeRemoteBrowserService( + youtubeRemoteBrowserConfig, + adminSettingsService, + svc.youtubeSessionService, + OkHttpYoutubeRemoteBrowserClient(youtubeRemoteBrowserConfig.serviceUrl), + ) val downloaderGatewayService = DownloaderGatewayService(downloaderServiceUrl) val openMojiProxyService = OpenMojiProxyService(cache) val internalHealthService = InternalHealthService(cache, downloaderGatewayService, subtitleServiceUrl) configurePlugins(authService) - routing { - internalObservabilityRoutes(internalHealthService::check) - publicMetadataRoutes(instanceService::getInstance) - rateLimit(STREAMS_ZONE) { - streamRoutes(svc.streamService, authService, svc.youtubeSessionStreamService::getStreamInfo) - manifestRoutes(svc.manifestService, svc.nativeManifestService, svc.hlsManifestService) - } - rateLimit(EXTRACTION_ZONE) { - searchRoutes(svc.searchService) - suggestionRoutes(svc.suggestionService) - trendingRoutes(svc.trendingService) - publicPlaylistRoutes(svc.publicPlaylistService) - commentRoutes(svc.commentService) - bulletCommentRoutes(svc.bulletCommentService) - } - rateLimit(CHANNEL_ZONE) { - channelRoutes(svc.channelService) - podcastRoutes(svc.podcastService) - } - rateLimit(PROXY_ZONE) { - proxyRoutes(svc.proxyService) - nicoVideoProxyRoutes(svc.nicoVideoProxyService) - } - rateLimit(PROXY_STORYBOARD_ZONE) { - storyboardProxyRoutes(svc.proxyService) - } - downloaderGatewayRoutes(downloaderGatewayService) - oidcAuthRoutes(oidcAuthService, adminSettingsService) - authRoutes(authService, passwordResetService, profileService, adminSettingsService, svc.homeRecommendationWarmupService) - adminRoutes(authService, userAdminService, passwordResetService, adminSettingsService) - adminSessionRoutes(authService, activeSessionService) - sessionActivityRoutes(authService, activeSessionService) - adminBugReportRoutes(authService, svc.bugReportService, gitHubIssueService) - avatarRoutes(avatarService, openMojiProxyService) - rateLimit(USER_DATA_ZONE) { userDataRoutes(svc, authService, profileService, avatarService, svc.bugReportService, restoreService) } - } + installApplicationRoutes( + svc = svc, + authService = authService, + adminSettingsService = adminSettingsService, + activeSessionService = activeSessionService, + downloaderGatewayService = downloaderGatewayService, + gitHubIssueService = gitHubIssueService, + instanceService = instanceService, + oidcAuthService = oidcAuthService, + passwordResetService = passwordResetService, + profileService = profileService, + userAdminService = userAdminService, + avatarService = avatarService, + openMojiProxyService = openMojiProxyService, + internalHealthService = internalHealthService, + restoreService = restoreService, + youtubeRemoteBrowserService = youtubeRemoteBrowserService, + ) } diff --git a/src/main/kotlin/dev/typetype/server/ApplicationRoutes.kt b/src/main/kotlin/dev/typetype/server/ApplicationRoutes.kt new file mode 100644 index 00000000..8b373c6c --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/ApplicationRoutes.kt @@ -0,0 +1,100 @@ +package dev.typetype.server + +import dev.typetype.server.routes.adminBugReportRoutes +import dev.typetype.server.routes.adminRoutes +import dev.typetype.server.routes.adminSessionRoutes +import dev.typetype.server.routes.authRoutes +import dev.typetype.server.routes.avatarRoutes +import dev.typetype.server.routes.bulletCommentRoutes +import dev.typetype.server.routes.channelRoutes +import dev.typetype.server.routes.commentRoutes +import dev.typetype.server.routes.downloaderGatewayRoutes +import dev.typetype.server.routes.internalObservabilityRoutes +import dev.typetype.server.routes.manifestRoutes +import dev.typetype.server.routes.nicoVideoProxyRoutes +import dev.typetype.server.routes.oidcAuthRoutes +import dev.typetype.server.routes.podcastRoutes +import dev.typetype.server.routes.proxyRoutes +import dev.typetype.server.routes.publicMetadataRoutes +import dev.typetype.server.routes.publicPlaylistRoutes +import dev.typetype.server.routes.searchRoutes +import dev.typetype.server.routes.sessionActivityRoutes +import dev.typetype.server.routes.storyboardProxyRoutes +import dev.typetype.server.routes.streamRoutes +import dev.typetype.server.routes.suggestionRoutes +import dev.typetype.server.routes.trendingRoutes +import dev.typetype.server.routes.userDataRoutes +import dev.typetype.server.routes.youtubeRemoteBrowserRoutes +import dev.typetype.server.services.ActiveSessionService +import dev.typetype.server.services.AdminSettingsService +import dev.typetype.server.services.AuthService +import dev.typetype.server.services.AvatarService +import dev.typetype.server.services.DownloaderGatewayService +import dev.typetype.server.services.GitHubIssueService +import dev.typetype.server.services.InstanceService +import dev.typetype.server.services.InternalHealthService +import dev.typetype.server.services.OidcAuthService +import dev.typetype.server.services.OpenMojiProxyService +import dev.typetype.server.services.PasswordResetService +import dev.typetype.server.services.PipePipeBackupImporterService +import dev.typetype.server.services.ProfileService +import dev.typetype.server.services.UserAdminService +import dev.typetype.server.services.YoutubeRemoteBrowserService +import io.ktor.server.application.Application +import io.ktor.server.plugins.ratelimit.rateLimit +import io.ktor.server.routing.routing + +internal fun Application.installApplicationRoutes( + svc: ServiceRegistry, + authService: AuthService, + adminSettingsService: AdminSettingsService, + activeSessionService: ActiveSessionService, + downloaderGatewayService: DownloaderGatewayService, + gitHubIssueService: GitHubIssueService, + instanceService: InstanceService, + oidcAuthService: OidcAuthService, + passwordResetService: PasswordResetService, + profileService: ProfileService, + userAdminService: UserAdminService, + avatarService: AvatarService, + openMojiProxyService: OpenMojiProxyService, + internalHealthService: InternalHealthService, + restoreService: PipePipeBackupImporterService, + youtubeRemoteBrowserService: YoutubeRemoteBrowserService, +) { + routing { + internalObservabilityRoutes(internalHealthService::check) + publicMetadataRoutes(instanceService::getInstance) + rateLimit(STREAMS_ZONE) { + streamRoutes(svc.streamService, authService, svc.youtubeSessionStreamService::getStreamInfo) + manifestRoutes(svc.manifestService, svc.nativeManifestService, svc.hlsManifestService) + } + rateLimit(EXTRACTION_ZONE) { + searchRoutes(svc.searchService) + suggestionRoutes(svc.suggestionService) + trendingRoutes(svc.trendingService) + publicPlaylistRoutes(svc.publicPlaylistService) + commentRoutes(svc.commentService) + bulletCommentRoutes(svc.bulletCommentService) + } + rateLimit(CHANNEL_ZONE) { + channelRoutes(svc.channelService) + podcastRoutes(svc.podcastService) + } + rateLimit(PROXY_ZONE) { + proxyRoutes(svc.proxyService) + nicoVideoProxyRoutes(svc.nicoVideoProxyService) + } + rateLimit(PROXY_STORYBOARD_ZONE) { storyboardProxyRoutes(svc.proxyService) } + downloaderGatewayRoutes(downloaderGatewayService) + oidcAuthRoutes(oidcAuthService, adminSettingsService) + authRoutes(authService, passwordResetService, profileService, adminSettingsService, svc.homeRecommendationWarmupService) + adminRoutes(authService, userAdminService, passwordResetService, adminSettingsService) + adminSessionRoutes(authService, activeSessionService) + sessionActivityRoutes(authService, activeSessionService) + adminBugReportRoutes(authService, svc.bugReportService, gitHubIssueService) + avatarRoutes(avatarService, openMojiProxyService) + rateLimit(USER_DATA_ZONE) { youtubeRemoteBrowserRoutes(youtubeRemoteBrowserService, authService) } + rateLimit(USER_DATA_ZONE) { userDataRoutes(svc, authService, profileService, avatarService, svc.bugReportService, restoreService) } + } +} diff --git a/src/main/kotlin/dev/typetype/server/Plugins.kt b/src/main/kotlin/dev/typetype/server/Plugins.kt index 270a7768..4bebe888 100644 --- a/src/main/kotlin/dev/typetype/server/Plugins.kt +++ b/src/main/kotlin/dev/typetype/server/Plugins.kt @@ -16,6 +16,7 @@ import io.ktor.server.plugins.ratelimit.RateLimitName import io.ktor.server.plugins.statuspages.StatusPages import io.ktor.server.request.path import io.ktor.server.response.respond +import io.ktor.server.websocket.WebSockets import kotlinx.serialization.json.Json import org.slf4j.LoggerFactory import kotlin.time.Duration.Companion.minutes @@ -44,6 +45,12 @@ fun Application.configurePlugins(authService: AuthService) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; encodeDefaults = true }) } + install(WebSockets) { + pingPeriodMillis = 15_000 + timeoutMillis = 30_000 + maxFrameSize = 16L * 1024 + masking = false + } configureCompression() val allowedOrigins = allowedOriginsFromEnv(System.getenv("ALLOWED_ORIGINS")) install(CORS) { diff --git a/src/main/kotlin/dev/typetype/server/routes/YoutubeRemoteBrowserRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/YoutubeRemoteBrowserRoutes.kt new file mode 100644 index 00000000..e683e5ff --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/routes/YoutubeRemoteBrowserRoutes.kt @@ -0,0 +1,70 @@ +package dev.typetype.server.routes + +import dev.typetype.server.models.ErrorResponse +import dev.typetype.server.models.YoutubeRemoteBrowserCompleteRequest +import dev.typetype.server.models.YoutubeRemoteBrowserStartRequest +import dev.typetype.server.services.AuthService +import dev.typetype.server.services.YoutubeRemoteBrowserCompleteResult +import dev.typetype.server.services.YoutubeRemoteBrowserService +import dev.typetype.server.services.YoutubeRemoteBrowserStartResult +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.call +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.post +import io.ktor.server.websocket.webSocket + +fun Route.youtubeRemoteBrowserRoutes(service: YoutubeRemoteBrowserService, authService: AuthService): Unit { + post("/youtube-session/browser/start") { + call.withJwtAuth(authService) { userId -> + val request = runCatching { call.receive() } + .getOrDefault(YoutubeRemoteBrowserStartRequest()) + when (val result = service.start(userId, request.returnTo)) { + is YoutubeRemoteBrowserStartResult.Started -> call.respond(HttpStatusCode.Created, result.response) + YoutubeRemoteBrowserStartResult.Disabled -> call.respond(HttpStatusCode.Forbidden, ErrorResponse("YouTube remote login is disabled", "youtube_remote_login_disabled")) + YoutubeRemoteBrowserStartResult.Misconfigured -> call.respond(HttpStatusCode.ServiceUnavailable, ErrorResponse("YouTube remote login is unavailable", "youtube_remote_login_unavailable")) + YoutubeRemoteBrowserStartResult.AlreadyActive -> call.respond(HttpStatusCode.Conflict, ErrorResponse("A YouTube remote login session is already active", "youtube_remote_login_already_active")) + YoutubeRemoteBrowserStartResult.CapacityReached -> call.respond(HttpStatusCode.TooManyRequests, ErrorResponse("Too many YouTube remote login sessions", "youtube_remote_login_capacity")) + YoutubeRemoteBrowserStartResult.TokenUnavailable -> call.respond(HttpStatusCode.ServiceUnavailable, ErrorResponse("YouTube remote login is unavailable", "youtube_remote_login_unavailable")) + } + } + } + + webSocket("/youtube-session/browser/{sessionId}") { + val sessionId = call.parameters["sessionId"].orEmpty() + val token = call.request.queryParameters["token"] + service.bridge(sessionId, token, this) + } + + delete("/youtube-session/browser/{sessionId}") { + call.withJwtAuth(authService) { userId -> + val sessionId = call.parameters["sessionId"].orEmpty() + if (service.cancel(userId, sessionId)) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(HttpStatusCode.NotFound, ErrorResponse("Remote login session not found", "youtube_remote_login_not_found")) + } + } + } + + post("/internal/youtube-remote-login/callback") { call.respondCompletion(service) } + post("/internal/youtube-session/browser/complete") { call.respondCompletion(service) } +} + +private suspend fun ApplicationCall.respondCompletion(service: YoutubeRemoteBrowserService) { + val payload = runCatching { receive() }.getOrElse { + return respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request body")) + } + when (service.complete(payload, request.headers[INTERNAL_HEADER])) { + YoutubeRemoteBrowserCompleteResult.Completed -> respond(HttpStatusCode.NoContent) + YoutubeRemoteBrowserCompleteResult.Unauthorized -> respond(HttpStatusCode.Unauthorized, ErrorResponse("Unauthorized", "unauthorized")) + YoutubeRemoteBrowserCompleteResult.NotFound -> respond(HttpStatusCode.NotFound, ErrorResponse("Remote login session not found", "youtube_remote_login_not_found")) + YoutubeRemoteBrowserCompleteResult.InvalidPayload -> respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid remote login payload", "youtube_remote_login_invalid_payload")) + YoutubeRemoteBrowserCompleteResult.InvalidCredentials -> respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid YouTube credentials", "youtube_credentials_invalid")) + } +} + +private const val INTERNAL_HEADER = "X-Internal-Token" From 542ec7e0c7329c4a6e5a6436b3dd5186c024c0fc Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 10:17:32 +0200 Subject: [PATCH 07/15] docs: document remote browser api --- openapi.yaml | 15 ++++ openapi/components/youtube-session.yaml | 26 ++++++ openapi/paths/youtube-session.yaml | 109 ++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 openapi/components/youtube-session.yaml create mode 100644 openapi/paths/youtube-session.yaml diff --git a/openapi.yaml b/openapi.yaml index 26b37d54..7ace83c8 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -12,6 +12,7 @@ tags: - name: health - name: extraction - name: downloader + - name: youtube-session paths: /health: $ref: ./openapi/paths/health.yaml#/Health @@ -51,6 +52,14 @@ paths: $ref: ./openapi/paths/downloader.yaml#/JobArtifact /downloader/jobs/{id}/cancel: $ref: ./openapi/paths/downloader.yaml#/JobCancel + /youtube-session/browser/start: + $ref: ./openapi/paths/youtube-session.yaml#/BrowserStart + /youtube-session/browser/{sessionId}: + $ref: ./openapi/paths/youtube-session.yaml#/BrowserSession + /internal/youtube-remote-login/callback: + $ref: ./openapi/paths/youtube-session.yaml#/InternalBrowserComplete + /internal/youtube-session/browser/complete: + $ref: ./openapi/paths/youtube-session.yaml#/InternalBrowserComplete components: schemas: ErrorResponse: @@ -87,3 +96,9 @@ components: $ref: ./openapi/components/media.yaml#/CommentsPageResponse DownloadJob: $ref: ./openapi/components/downloader.yaml#/DownloadJob + YoutubeRemoteBrowserStartRequest: + $ref: ./openapi/components/youtube-session.yaml#/YoutubeRemoteBrowserStartRequest + YoutubeRemoteBrowserStartResponse: + $ref: ./openapi/components/youtube-session.yaml#/YoutubeRemoteBrowserStartResponse + YoutubeRemoteBrowserCompleteRequest: + $ref: ./openapi/components/youtube-session.yaml#/YoutubeRemoteBrowserCompleteRequest diff --git a/openapi/components/youtube-session.yaml b/openapi/components/youtube-session.yaml new file mode 100644 index 00000000..8ce67139 --- /dev/null +++ b/openapi/components/youtube-session.yaml @@ -0,0 +1,26 @@ +YoutubeRemoteBrowserStartRequest: + type: object + properties: + returnTo: + type: string + nullable: true + maxLength: 2048 +YoutubeRemoteBrowserStartResponse: + type: object + required: [sessionId, wsUrl, expiresAt] + properties: + sessionId: { type: string } + wsUrl: { type: string } + expiresAt: { type: integer, format: int64 } +YoutubeRemoteBrowserCompleteRequest: + type: object + required: [sessionId, tokenSessionId, status, cookies, poToken, capturedAt] + properties: + sessionId: { type: string } + tokenSessionId: { type: string } + status: + type: string + enum: [completed] + cookies: { type: string } + poToken: { type: string } + capturedAt: { type: integer, format: int64 } diff --git a/openapi/paths/youtube-session.yaml b/openapi/paths/youtube-session.yaml new file mode 100644 index 00000000..2e22552b --- /dev/null +++ b/openapi/paths/youtube-session.yaml @@ -0,0 +1,109 @@ +BrowserStart: + post: + tags: [youtube-session] + summary: Start a temporary remote Chromium login session + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + returnTo: + type: string + nullable: true + maxLength: 2048 + responses: + '201': + description: Remote browser session created. + content: + application/json: + schema: + type: object + required: [sessionId, wsUrl, expiresAt] + properties: + sessionId: { type: string } + wsUrl: { type: string } + expiresAt: { type: integer, format: int64 } + '401': { $ref: '#/components/responses/JsonError' } + '403': { $ref: '#/components/responses/JsonError' } + '409': { $ref: '#/components/responses/JsonError' } + '429': { $ref: '#/components/responses/JsonError' } + '503': { $ref: '#/components/responses/JsonError' } +BrowserSession: + parameters: + - name: sessionId + in: path + required: true + schema: { type: string } + get: + tags: [youtube-session] + summary: WebSocket endpoint for remote browser frames and input + parameters: + - name: token + in: query + required: true + schema: { type: string } + responses: + '101': { description: WebSocket upgrade accepted. } + '401': { $ref: '#/components/responses/JsonError' } + delete: + tags: [youtube-session] + summary: Cancel a temporary remote browser login session + responses: + '204': { description: Remote browser session cancelled. } + '401': { $ref: '#/components/responses/JsonError' } + '404': { $ref: '#/components/responses/JsonError' } +InternalBrowserComplete: + post: + tags: [youtube-session] + summary: Internal callback used by TypeType-Token after capture + parameters: + - name: X-Internal-Token + in: header + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sessionId, tokenSessionId, status, cookies, poToken, capturedAt] + properties: + sessionId: { type: string } + tokenSessionId: { type: string } + status: + type: string + enum: [completed] + cookies: { type: string } + poToken: { type: string } + capturedAt: { type: integer, format: int64 } + responses: + '204': { description: YouTube credentials stored. } + '400': { $ref: '#/components/responses/JsonError' } + '401': { $ref: '#/components/responses/JsonError' } + '404': { $ref: '#/components/responses/JsonError' } +components: + schemas: + ErrorResponse: + type: object + required: [error] + properties: + error: { type: string } + code: { type: string, default: error } + requestId: { type: string, nullable: true } + headers: + RequestIdHeader: + description: Request correlation id returned by TypeType-Server. + schema: { type: string } + responses: + JsonError: + description: Request failed. + headers: + X-Request-ID: + $ref: '#/components/headers/RequestIdHeader' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' From 481a3af43607bdda44b6b739dbc18955ca44ccdb Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 10:17:57 +0200 Subject: [PATCH 08/15] test: cover remote browser start --- .../server/FakeRemoteBrowserClient.kt | 24 ++++ .../YoutubeRemoteBrowserMessageGuardTest.kt | 33 ++++++ .../server/YoutubeRemoteBrowserRoutesTest.kt | 112 ++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/test/kotlin/dev/typetype/server/FakeRemoteBrowserClient.kt create mode 100644 src/test/kotlin/dev/typetype/server/YoutubeRemoteBrowserMessageGuardTest.kt create mode 100644 src/test/kotlin/dev/typetype/server/YoutubeRemoteBrowserRoutesTest.kt diff --git a/src/test/kotlin/dev/typetype/server/FakeRemoteBrowserClient.kt b/src/test/kotlin/dev/typetype/server/FakeRemoteBrowserClient.kt new file mode 100644 index 00000000..aaf0e7be --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/FakeRemoteBrowserClient.kt @@ -0,0 +1,24 @@ +package dev.typetype.server + +import dev.typetype.server.services.YoutubeRemoteBrowserClient +import dev.typetype.server.services.YoutubeRemoteBrowserConfig +import dev.typetype.server.services.YoutubeRemoteBrowserTokenStartRequest +import dev.typetype.server.services.YoutubeRemoteBrowserTokenStartResponse +import io.ktor.server.websocket.DefaultWebSocketServerSession + +class FakeRemoteBrowserClient : YoutubeRemoteBrowserClient { + override suspend fun start( + request: YoutubeRemoteBrowserTokenStartRequest, + internalToken: String, + ): YoutubeRemoteBrowserTokenStartResponse = + YoutubeRemoteBrowserTokenStartResponse("token-session", System.currentTimeMillis() + 480_000) + + override suspend fun cancel(tokenSessionId: String, internalToken: String): Boolean = true + + override suspend fun bridge( + serverSession: DefaultWebSocketServerSession, + tokenSessionId: String, + internalToken: String, + config: YoutubeRemoteBrowserConfig, + ): Unit = Unit +} diff --git a/src/test/kotlin/dev/typetype/server/YoutubeRemoteBrowserMessageGuardTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeRemoteBrowserMessageGuardTest.kt new file mode 100644 index 00000000..2469785f --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/YoutubeRemoteBrowserMessageGuardTest.kt @@ -0,0 +1,33 @@ +package dev.typetype.server + +import dev.typetype.server.services.YoutubeRemoteBrowserMessageGuard +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class YoutubeRemoteBrowserMessageGuardTest { + @Test + fun `frontend messages allow only bounded remote browser input`() { + val resize = """{"type":"resize","width":1280,"height":720}""" + val pointer = """{"type":"pointer","event":"move","x":420,"y":240,"button":"left"}""" + val key = """{"type":"key","event":"down","key":"A"}""" + + assertEquals(resize, YoutubeRemoteBrowserMessageGuard.frontendText(resize, 4096)) + assertEquals(pointer, YoutubeRemoteBrowserMessageGuard.frontendText(pointer, 4096)) + assertEquals(key, YoutubeRemoteBrowserMessageGuard.frontendText(key, 4096)) + assertNull(YoutubeRemoteBrowserMessageGuard.frontendText("""{"type":"resize","width":9000,"height":720}""", 4096)) + assertNull(YoutubeRemoteBrowserMessageGuard.frontendText("""{"type":"text","value":"secret"}""", 8)) + } + + @Test + fun `token messages never forward credentials to frontend`() { + val status = """{"type":"status","phase":"awaiting_login"}""" + val error = """{"type":"error","message":"Session expired"}""" + val complete = """{"type":"complete","cookies":"SID=secret","poToken":"secret"}""" + + assertEquals(status, YoutubeRemoteBrowserMessageGuard.tokenText(status)) + assertEquals(error, YoutubeRemoteBrowserMessageGuard.tokenText(error)) + assertNull(YoutubeRemoteBrowserMessageGuard.tokenText(complete)) + assertNull(YoutubeRemoteBrowserMessageGuard.tokenText("""{"type":"status","phase":"unknown"}""")) + } +} diff --git a/src/test/kotlin/dev/typetype/server/YoutubeRemoteBrowserRoutesTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeRemoteBrowserRoutesTest.kt new file mode 100644 index 00000000..c554df5c --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/YoutubeRemoteBrowserRoutesTest.kt @@ -0,0 +1,112 @@ +package dev.typetype.server + +import dev.typetype.server.models.AdminSettingsItem +import dev.typetype.server.routes.youtubeRemoteBrowserRoutes +import dev.typetype.server.services.AdminSettingsService +import dev.typetype.server.services.AuthService +import dev.typetype.server.services.YoutubeRemoteBrowserConfig +import dev.typetype.server.services.YoutubeRemoteBrowserService +import dev.typetype.server.services.YoutubeSessionCrypto +import dev.typetype.server.services.YoutubeSessionPairingStore +import dev.typetype.server.services.YoutubeSessionService +import dev.typetype.server.services.YoutubeSessionStore +import io.ktor.client.request.delete +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +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.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import io.ktor.server.websocket.WebSockets +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class YoutubeRemoteBrowserRoutesTest { + private val auth = AuthService.fixed(TEST_USER_ID) + private val adminSettings = AdminSettingsService() + private val json = Json { ignoreUnknownKeys = true } + private val youtubeSessionService = YoutubeSessionService( + YoutubeSessionCrypto.fromSecret("test-youtube-session-key-32-bytes"), + YoutubeSessionPairingStore(codeGenerator = { "ABC12345" }), + YoutubeSessionStore(), + ) + + companion object { + @BeforeAll + @JvmStatic + fun initDb() { TestDatabase.setup() } + } + + @BeforeEach + fun clean() { TestDatabase.truncateAll() } + + @Test + fun `start is unavailable when internal config is missing`() = withApp(config(internalToken = null)) { + adminSettings.upsert(AdminSettingsItem(youtubeRemoteLoginEnabled = true)) + assertEquals(HttpStatusCode.ServiceUnavailable, start().status) + } + + @Test + fun `start is disabled until admin enables it`() = withApp(config()) { + assertEquals(HttpStatusCode.Forbidden, start().status) + } + + @Test + fun `start returns ws url and enforces one active session per user`() = withApp(config()) { + adminSettings.upsert(AdminSettingsItem(youtubeRemoteLoginEnabled = true)) + val first = start() + assertEquals(HttpStatusCode.Created, first.status) + val root = json.parseToJsonElement(first.bodyAsText()).jsonObject + assertTrue(root["wsUrl"]?.jsonPrimitive?.contentOrNull.orEmpty().contains("?token=")) + assertEquals(HttpStatusCode.Conflict, start().status) + } + + @Test + fun `cancel only removes owner session`() = withApp(config()) { + adminSettings.upsert(AdminSettingsItem(youtubeRemoteLoginEnabled = true)) + val root = json.parseToJsonElement(start().bodyAsText()).jsonObject + val sessionId = root["sessionId"]?.jsonPrimitive?.contentOrNull.orEmpty() + assertEquals(HttpStatusCode.NoContent, client.delete("/youtube-session/browser/$sessionId") { auth() }.status) + } + + private fun withApp(config: YoutubeRemoteBrowserConfig, block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + val remoteService = YoutubeRemoteBrowserService(config, adminSettings, youtubeSessionService, FakeRemoteBrowserClient()) + application { + install(ContentNegotiation) { json(json) } + install(WebSockets) + routing { + youtubeRemoteBrowserRoutes(remoteService, auth) + } + } + block() + } + + private fun config(internalToken: String? = "internal-token") = + YoutubeRemoteBrowserConfig("http://token", "http://server", internalToken, 480_000, 1, 512 * 1024, 4096, 2) + + private suspend fun ApplicationTestBuilder.start() = client.post("/youtube-session/browser/start") { + auth() + contentType(ContentType.Application.Json) + setBody("""{}""") + } + + private fun io.ktor.client.request.HttpRequestBuilder.auth() { + header(HttpHeaders.Authorization, "Bearer test-jwt") + } + +} From c6b58c7372d3aef7e663a2d76a30bd1603ee6dcb Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 10:18:09 +0200 Subject: [PATCH 09/15] test: cover remote browser callback --- .../YoutubeRemoteBrowserCompleteRoutesTest.kt | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 src/test/kotlin/dev/typetype/server/YoutubeRemoteBrowserCompleteRoutesTest.kt diff --git a/src/test/kotlin/dev/typetype/server/YoutubeRemoteBrowserCompleteRoutesTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeRemoteBrowserCompleteRoutesTest.kt new file mode 100644 index 00000000..8e55fda6 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/YoutubeRemoteBrowserCompleteRoutesTest.kt @@ -0,0 +1,115 @@ +package dev.typetype.server + +import dev.typetype.server.db.DatabaseFactory +import dev.typetype.server.db.tables.YoutubeSessionsTable +import dev.typetype.server.models.AdminSettingsItem +import dev.typetype.server.routes.youtubeRemoteBrowserRoutes +import dev.typetype.server.routes.youtubeSessionRoutes +import dev.typetype.server.services.AdminSettingsService +import dev.typetype.server.services.AuthService +import dev.typetype.server.services.YoutubeRemoteBrowserConfig +import dev.typetype.server.services.YoutubeRemoteBrowserService +import dev.typetype.server.services.YoutubeSessionCrypto +import dev.typetype.server.services.YoutubeSessionPairingStore +import dev.typetype.server.services.YoutubeSessionService +import dev.typetype.server.services.YoutubeSessionStore +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +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.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import io.ktor.server.websocket.WebSockets +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class YoutubeRemoteBrowserCompleteRoutesTest { + private val auth = AuthService.fixed(TEST_USER_ID) + private val adminSettings = AdminSettingsService() + private val json = Json { ignoreUnknownKeys = true } + private val youtubeSessionService = YoutubeSessionService( + YoutubeSessionCrypto.fromSecret("test-youtube-session-key-32-bytes"), + YoutubeSessionPairingStore(codeGenerator = { "ABC12345" }), + YoutubeSessionStore(), + ) + + companion object { + @BeforeAll + @JvmStatic + fun initDb() { TestDatabase.setup() } + } + + @BeforeEach + fun clean() { TestDatabase.truncateAll() } + + @Test + fun `internal callback stores encrypted youtube session`() = withApp { + adminSettings.upsert(AdminSettingsItem(youtubeRemoteLoginEnabled = true)) + val root = json.parseToJsonElement(start().bodyAsText()).jsonObject + val sessionId = root["sessionId"]?.jsonPrimitive?.contentOrNull.orEmpty() + val complete = client.post("/internal/youtube-remote-login/callback") { + header("X-Internal-Token", "internal-token") + contentType(ContentType.Application.Json) + setBody(completeBody(sessionId)) + } + assertEquals(HttpStatusCode.NoContent, complete.status) + val status = client.get("/youtube-session/status") { auth() }.bodyAsText() + assertTrue(status.contains("\"status\":\"connected\"")) + assertCredentialsAreEncrypted() + } + + private fun withApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + val config = YoutubeRemoteBrowserConfig("http://token", "http://server", "internal-token", 480_000, 1, 512 * 1024, 4096, 2) + val remoteService = YoutubeRemoteBrowserService(config, adminSettings, youtubeSessionService, FakeRemoteBrowserClient()) + application { + install(ContentNegotiation) { json(json) } + install(WebSockets) + routing { + youtubeRemoteBrowserRoutes(remoteService, auth) + youtubeSessionRoutes(youtubeSessionService, auth) + } + } + block() + } + + private suspend fun ApplicationTestBuilder.start() = client.post("/youtube-session/browser/start") { + auth() + contentType(ContentType.Application.Json) + setBody("""{}""") + } + + private fun io.ktor.client.request.HttpRequestBuilder.auth() { + header(HttpHeaders.Authorization, "Bearer test-jwt") + } + + private fun completeBody(sessionId: String): String = + """{"sessionId":"$sessionId","tokenSessionId":"token-session","status":"completed","cookies":"SID=secret-cookie; SAPISID=secret-sapisid","poToken":"secret-pot-value","capturedAt":123}""" + + private suspend fun assertCredentialsAreEncrypted() { + val encrypted = DatabaseFactory.query { + YoutubeSessionsTable.selectAll().where { YoutubeSessionsTable.userId eq TEST_USER_ID }.single() + .let { it[YoutubeSessionsTable.encryptedCookies] to it[YoutubeSessionsTable.encryptedPoToken] } + } + assertFalse(encrypted.first.contains("secret-cookie")) + assertFalse(encrypted.second.contains("secret-pot")) + } +} From 9105580611cbd08c2b3cfbaf89d22f3a6621f6ff Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 10:18:33 +0200 Subject: [PATCH 10/15] chore: use token beta in mirror stack --- docker-compose.dev-mirror.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.dev-mirror.yml b/docker-compose.dev-mirror.yml index 115e947c..8e3c1188 100644 --- a/docker-compose.dev-mirror.yml +++ b/docker-compose.dev-mirror.yml @@ -56,7 +56,7 @@ services: typetype-token: { condition: service_started } restart: unless-stopped typetype-token: - image: ghcr.io/priveetee/typetype-token:latest + image: ghcr.io/priveetee/typetype-token-beta:beta pull_policy: always ports: ["28081:8081"] init: true From 0e2e6f88b5ac87dd989eefa34a57ca0cb13d793b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 10:20:27 +0200 Subject: [PATCH 11/15] chore: configure beta remote login stack --- docker-compose.dev-mirror.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docker-compose.dev-mirror.yml b/docker-compose.dev-mirror.yml index 8e3c1188..40452afa 100644 --- a/docker-compose.dev-mirror.yml +++ b/docker-compose.dev-mirror.yml @@ -18,6 +18,12 @@ services: ALLOWED_ORIGINS: http://localhost:28082,http://127.0.0.1:28082 SUBTITLE_SERVICE_URL: http://typetype-token:8081 DOWNLOADER_SERVICE_URL: http://typetype-downloader:18093 + YOUTUBE_SESSION_ENCRYPTION_KEY: beta-youtube-session-key-change-before-prod + YOUTUBE_REMOTE_LOGIN_SERVICE_URL: http://typetype-token:8081 + YOUTUBE_REMOTE_LOGIN_CALLBACK_BASE_URL: http://typetype-server:8080 + YOUTUBE_REMOTE_LOGIN_INTERNAL_TOKEN: beta-remote-login-internal-token + YOUTUBE_REMOTE_LOGIN_TTL_MS: "480000" + YOUTUBE_REMOTE_LOGIN_MAX_SESSIONS: "2" depends_on: postgres-init: { condition: service_completed_successfully } dragonfly: { condition: service_started } @@ -61,7 +67,14 @@ services: ports: ["28081:8081"] init: true ipc: host - environment: ["NODE_ENV=production"] + environment: + NODE_ENV: production + YOUTUBE_REMOTE_LOGIN_ENABLED: "true" + YOUTUBE_REMOTE_LOGIN_INTERNAL_TOKEN: beta-remote-login-internal-token + YOUTUBE_REMOTE_LOGIN_CALLBACK_ORIGIN: http://typetype-server:8080 + YOUTUBE_REMOTE_LOGIN_MAX_SESSIONS: "2" + YOUTUBE_REMOTE_LOGIN_FRAME_FPS: "10" + YOUTUBE_REMOTE_LOGIN_MAX_FRAME_BYTES: "524288" restart: unless-stopped postgres: image: postgres:17 From abf9e233b1c32b7ff809a495cac2bd83a98c8204 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 12:32:16 +0200 Subject: [PATCH 12/15] feat: sign authenticated hls manifests --- .../services/SignedHlsManifestCookie.kt | 42 ++++++++++ .../server/services/SignedHlsManifestToken.kt | 14 ++++ .../services/SignedHlsManifestTokenService.kt | 78 +++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 src/main/kotlin/dev/typetype/server/services/SignedHlsManifestCookie.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/SignedHlsManifestToken.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/SignedHlsManifestTokenService.kt diff --git a/src/main/kotlin/dev/typetype/server/services/SignedHlsManifestCookie.kt b/src/main/kotlin/dev/typetype/server/services/SignedHlsManifestCookie.kt new file mode 100644 index 00000000..870c59eb --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/SignedHlsManifestCookie.kt @@ -0,0 +1,42 @@ +package dev.typetype.server.services + +import io.ktor.http.Cookie +import io.ktor.http.CookieEncoding +import io.ktor.server.application.ApplicationCall +import io.ktor.server.response.ApplicationResponse +import java.net.URI + +object SignedHlsManifestCookie { + fun append(response: ApplicationResponse, videoUrl: String, token: String): Unit { + cookiePaths.forEach { path -> + response.cookies.append( + Cookie( + name = name(videoUrl), + value = token, + httpOnly = true, + path = path, + maxAge = SignedHlsManifestTokenService.TTL_SECONDS.toInt(), + encoding = CookieEncoding.RAW, + extensions = mapOf("SameSite" to "Lax"), + ) + ) + } + } + + fun read(call: ApplicationCall, videoUrl: String): String? = + call.request.cookies[name(videoUrl)] + + fun tokenFromPath(path: String): String? = + runCatching { + URI(path).rawQuery + ?.split("&") + ?.firstOrNull { it.startsWith("token=") } + ?.substringAfter("token=") + ?.takeIf { it.isNotBlank() } + }.getOrNull() + + fun name(videoUrl: String): String = + "typetype_hls_${PublicCacheKey.of("hls-cookie", videoUrl).substringAfterLast(":")}" + + private val cookiePaths = listOf("/api/streams/hls-manifest", "/streams/hls-manifest") +} diff --git a/src/main/kotlin/dev/typetype/server/services/SignedHlsManifestToken.kt b/src/main/kotlin/dev/typetype/server/services/SignedHlsManifestToken.kt new file mode 100644 index 00000000..66595012 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/SignedHlsManifestToken.kt @@ -0,0 +1,14 @@ +package dev.typetype.server.services + +data class SignedHlsManifestToken( + val userId: String, + val videoUrl: String, + val fingerprint: String, + val expiresAt: Long, +) + +sealed interface SignedHlsManifestTokenResult { + data class Valid(val token: SignedHlsManifestToken) : SignedHlsManifestTokenResult + data object Invalid : SignedHlsManifestTokenResult + data object Expired : SignedHlsManifestTokenResult +} diff --git a/src/main/kotlin/dev/typetype/server/services/SignedHlsManifestTokenService.kt b/src/main/kotlin/dev/typetype/server/services/SignedHlsManifestTokenService.kt new file mode 100644 index 00000000..ba3f8fc7 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/SignedHlsManifestTokenService.kt @@ -0,0 +1,78 @@ +package dev.typetype.server.services + +import java.security.MessageDigest +import java.util.Base64 +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +class SignedHlsManifestTokenService( + secret: String, + private val nowMillis: () -> Long = System::currentTimeMillis, +) { + private val key = MessageDigest.getInstance("SHA-256") + .digest("typetype.hls-manifest.$secret".toByteArray(Charsets.UTF_8)) + + fun createToken(userId: String, videoUrl: String, fingerprint: String): String { + val expiresAt = nowMillis() + TTL_SECONDS * 1000L + val payload = listOf( + VERSION, + encode(userId), + encode(videoUrl), + encode(fingerprint), + expiresAt.toString(), + ).joinToString(".") + return "$payload.${signature(payload)}" + } + + fun createPath(userId: String, videoUrl: String, fingerprint: String): String = + "/streams/hls-manifest?token=${createToken(userId, videoUrl, fingerprint)}" + + fun verify(raw: String): SignedHlsManifestTokenResult { + if (raw.length > MAX_TOKEN_LENGTH) return SignedHlsManifestTokenResult.Invalid + val parts = raw.split(".") + if (parts.size != PART_COUNT || parts[0] != VERSION) return SignedHlsManifestTokenResult.Invalid + val payload = parts.take(PART_COUNT - 1).joinToString(".") + if (!sameSignature(parts.last(), signature(payload))) return SignedHlsManifestTokenResult.Invalid + val expiresAt = parts[4].toLongOrNull() ?: return SignedHlsManifestTokenResult.Invalid + if (expiresAt <= nowMillis()) return SignedHlsManifestTokenResult.Expired + val userId = decode(parts[1]) ?: return SignedHlsManifestTokenResult.Invalid + val videoUrl = decode(parts[2]) ?: return SignedHlsManifestTokenResult.Invalid + val fingerprint = decode(parts[3]) ?: return SignedHlsManifestTokenResult.Invalid + return SignedHlsManifestTokenResult.Valid( + SignedHlsManifestToken( + userId = userId, + videoUrl = videoUrl, + fingerprint = fingerprint, + expiresAt = expiresAt, + ) + ) + } + + private fun signature(payload: String): String { + val mac = Mac.getInstance(HMAC_ALGORITHM) + mac.init(SecretKeySpec(key, HMAC_ALGORITHM)) + return encoder.encodeToString(mac.doFinal(payload.toByteArray(Charsets.UTF_8))) + } + + private fun sameSignature(left: String, right: String): Boolean { + val leftBytes = runCatching { decoder.decode(left) }.getOrNull() ?: return false + val rightBytes = runCatching { decoder.decode(right) }.getOrNull() ?: return false + return MessageDigest.isEqual(leftBytes, rightBytes) + } + + private fun encode(value: String): String = + encoder.encodeToString(value.toByteArray(Charsets.UTF_8)) + + private fun decode(value: String): String? = + runCatching { String(decoder.decode(value), Charsets.UTF_8) }.getOrNull() + + companion object { + const val TTL_SECONDS = 900L + private const val VERSION = "v1" + private const val PART_COUNT = 6 + private const val MAX_TOKEN_LENGTH = 4096 + private const val HMAC_ALGORITHM = "HmacSHA256" + private val encoder = Base64.getUrlEncoder().withoutPadding() + private val decoder = Base64.getUrlDecoder() + } +} From 1991d77acd57edb22fc08512307a4d5bc4ecd920 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 12:32:22 +0200 Subject: [PATCH 13/15] feat: route authenticated hls manifests --- .../dev/typetype/server/ApplicationRoutes.kt | 8 ++- .../server/ExtractionServiceRegistry.kt | 11 +++- .../dev/typetype/server/ServiceRegistry.kt | 1 + .../typetype/server/routes/ManifestRoutes.kt | 54 ++++++++++++++++--- .../typetype/server/routes/StreamRoutes.kt | 5 ++ .../server/services/HlsManifestService.kt | 14 +++++ .../YoutubeSessionHlsManifestService.kt | 48 +++++++++++++++++ .../services/YoutubeSessionStreamService.kt | 30 +++++++++-- 8 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeSessionHlsManifestService.kt diff --git a/src/main/kotlin/dev/typetype/server/ApplicationRoutes.kt b/src/main/kotlin/dev/typetype/server/ApplicationRoutes.kt index 8b373c6c..ae7e4409 100644 --- a/src/main/kotlin/dev/typetype/server/ApplicationRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/ApplicationRoutes.kt @@ -67,7 +67,13 @@ internal fun Application.installApplicationRoutes( publicMetadataRoutes(instanceService::getInstance) rateLimit(STREAMS_ZONE) { streamRoutes(svc.streamService, authService, svc.youtubeSessionStreamService::getStreamInfo) - manifestRoutes(svc.manifestService, svc.nativeManifestService, svc.hlsManifestService) + manifestRoutes( + svc.manifestService, + svc.nativeManifestService, + svc.hlsManifestService, + svc.youtubeSessionHlsManifestService, + authService, + ) } rateLimit(EXTRACTION_ZONE) { searchRoutes(svc.searchService) diff --git a/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt b/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt index f38128fb..4795c4a8 100644 --- a/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt +++ b/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt @@ -28,6 +28,7 @@ import dev.typetype.server.services.PipePipeSearchService import dev.typetype.server.services.PipePipeStreamService import dev.typetype.server.services.PipePipeSuggestionService import dev.typetype.server.services.PipePipeTrendingService +import dev.typetype.server.services.SignedHlsManifestTokenService import dev.typetype.server.services.YouTubeSubtitleService import dev.typetype.server.services.YoutubeScopedChannelService import dev.typetype.server.services.YoutubeScopedCommentService @@ -37,6 +38,7 @@ import dev.typetype.server.services.YoutubeScopedStreamService import dev.typetype.server.services.YoutubeScopedSuggestionService import dev.typetype.server.services.YoutubeScopedTrendingService import dev.typetype.server.services.YoutubeSessionCrypto +import dev.typetype.server.services.YoutubeSessionHlsManifestService import dev.typetype.server.services.YoutubeSessionService import dev.typetype.server.services.YoutubeSessionStreamService import okhttp3.OkHttpClient @@ -59,7 +61,8 @@ internal class ExtractionServiceRegistry( BilibiliRelatedService(), ) val youtubeSessionService = YoutubeSessionService(YoutubeSessionCrypto.fromSecret(youtubeSessionEncryptionKey)) - val youtubeSessionStreamService = YoutubeSessionStreamService(pipePipeStreamService, youtubeSessionService, cache) + private val hlsTokenService = SignedHlsManifestTokenService(youtubeSessionEncryptionKey) + val youtubeSessionStreamService = YoutubeSessionStreamService(pipePipeStreamService, youtubeSessionService, cache, hlsTokenService) val streamService = CachedStreamService(YoutubeScopedStreamService(pipePipeStreamService), cache) val searchService = CachedSearchService(YoutubeScopedSearchService(PipePipeSearchService()), cache) val trendingService = CachedTrendingService( @@ -79,5 +82,11 @@ internal class ExtractionServiceRegistry( val manifestService = CachedManifestService(ManifestService(streamService), cache) val nativeManifestService = CachedNativeManifestService(NativeManifestService(), cache) val hlsManifestService = HlsManifestService(streamService, proxyHttpClient) + val youtubeSessionHlsManifestService = YoutubeSessionHlsManifestService( + youtubeSessionService, + youtubeSessionStreamService, + hlsManifestService, + hlsTokenService, + ) val suggestionService = CachedSuggestionService(YoutubeScopedSuggestionService(PipePipeSuggestionService()), cache) } diff --git a/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt b/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt index 1b97e153..5bfed501 100644 --- a/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt +++ b/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt @@ -44,6 +44,7 @@ internal class ServiceRegistry( val manifestService = extraction.manifestService val nativeManifestService = extraction.nativeManifestService val hlsManifestService = extraction.hlsManifestService + val youtubeSessionHlsManifestService = extraction.youtubeSessionHlsManifestService val suggestionService = extraction.suggestionService val historyService = HistoryService() val subscriptionsService = SubscriptionsService() diff --git a/src/main/kotlin/dev/typetype/server/routes/ManifestRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/ManifestRoutes.kt index e69753d4..b7cc88df 100644 --- a/src/main/kotlin/dev/typetype/server/routes/ManifestRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/ManifestRoutes.kt @@ -2,17 +2,29 @@ package dev.typetype.server.routes import dev.typetype.server.models.ErrorResponse import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.services.AuthService import dev.typetype.server.services.CachedManifestService import dev.typetype.server.services.CachedNativeManifestService import dev.typetype.server.services.HlsManifestService +import dev.typetype.server.services.SignedHlsManifestCookie +import dev.typetype.server.services.YoutubeSessionHlsManifestService +import dev.typetype.server.services.isManifestUrl import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall import io.ktor.server.response.respond import io.ktor.server.response.respondText import io.ktor.server.routing.Route import io.ktor.server.routing.get -fun Route.manifestRoutes(manifestService: CachedManifestService, nativeManifestService: CachedNativeManifestService, hlsManifestService: HlsManifestService) { +fun Route.manifestRoutes( + manifestService: CachedManifestService, + nativeManifestService: CachedNativeManifestService, + hlsManifestService: HlsManifestService, + youtubeSessionHlsManifestService: YoutubeSessionHlsManifestService? = null, + authService: AuthService? = null, +) { get("/streams/manifest") { val url = call.request.queryParameters["url"] ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing 'url' parameter")) @@ -42,16 +54,42 @@ fun Route.manifestRoutes(manifestService: CachedManifestService, nativeManifestS } get("/streams/hls-manifest") { + val token = call.request.queryParameters["token"] + if (token != null && youtubeSessionHlsManifestService != null) { + call.respondHlsResult(youtubeSessionHlsManifestService.hlsManifest(token), noStore = true) + return@get + } + val url = call.request.queryParameters["url"] ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing 'url' parameter")) - when (val result = hlsManifestService.hlsManifest(url)) { - is ExtractionResult.Success -> - call.respondText(result.data, ContentType.parse("application/vnd.apple.mpegurl")) - is ExtractionResult.BadRequest -> - call.respond(HttpStatusCode.BadRequest, ErrorResponse(result.message)) - is ExtractionResult.Failure -> - call.respond(HttpStatusCode.UnprocessableEntity, ErrorResponse(result.message)) + val cookieToken = SignedHlsManifestCookie.read(call, url) + if (cookieToken != null && youtubeSessionHlsManifestService != null) { + call.respondHlsResult(youtubeSessionHlsManifestService.hlsManifest(cookieToken, url), noStore = true) + return@get } + + val userId = authService?.let { call.optionalJwtUserId(it) } + if (userId != null && youtubeSessionHlsManifestService != null && !isManifestUrl(url)) { + call.respondHlsResult(youtubeSessionHlsManifestService.hlsManifestForUser(userId, url), noStore = true) + return@get + } + + call.respondHlsResult(hlsManifestService.hlsManifest(url)) + } +} + +private suspend fun ApplicationCall.respondHlsResult( + result: ExtractionResult, + noStore: Boolean = false, +): Unit { + if (noStore) response.headers.append(HttpHeaders.CacheControl, "no-store") + when (result) { + is ExtractionResult.Success -> + respondText(result.data, ContentType.parse("application/vnd.apple.mpegurl")) + is ExtractionResult.BadRequest -> + respond(HttpStatusCode.BadRequest, ErrorResponse(result.message)) + is ExtractionResult.Failure -> + respond(HttpStatusCode.UnprocessableEntity, ErrorResponse(result.message)) } } diff --git a/src/main/kotlin/dev/typetype/server/routes/StreamRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/StreamRoutes.kt index 71d3c908..4344d92e 100644 --- a/src/main/kotlin/dev/typetype/server/routes/StreamRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/StreamRoutes.kt @@ -4,6 +4,7 @@ import dev.typetype.server.models.ErrorResponse import dev.typetype.server.models.ExtractionResult import dev.typetype.server.models.StreamResponse import dev.typetype.server.services.AuthService +import dev.typetype.server.services.SignedHlsManifestCookie import dev.typetype.server.services.StreamService import dev.typetype.server.services.YOUTUBE_SESSION_RECONNECT_ERROR import io.ktor.http.HttpStatusCode @@ -42,6 +43,10 @@ fun Route.streamRoutes( HttpHeaders.CacheControl, if (usedYoutubeSession) AUTHENTICATED_STREAMS_CACHE_CONTROL else STREAMS_CACHE_CONTROL, ) + if (usedYoutubeSession) { + SignedHlsManifestCookie.tokenFromPath(result.data.hlsUrl) + ?.let { SignedHlsManifestCookie.append(call.response, url, it) } + } call.respond(result.data) } is ExtractionResult.BadRequest -> call.respond(HttpStatusCode.BadRequest, result.toErrorResponse()) diff --git a/src/main/kotlin/dev/typetype/server/services/HlsManifestService.kt b/src/main/kotlin/dev/typetype/server/services/HlsManifestService.kt index 98db4a55..79143f3f 100644 --- a/src/main/kotlin/dev/typetype/server/services/HlsManifestService.kt +++ b/src/main/kotlin/dev/typetype/server/services/HlsManifestService.kt @@ -1,6 +1,7 @@ package dev.typetype.server.services import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.StreamResponse import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -52,8 +53,21 @@ class HlsManifestService( return fetchAndRewrite(manifestUrl) } + suspend fun hlsManifestFromStreamInfo(result: ExtractionResult): ExtractionResult { + val manifestUrl = when (val resolved = resolveHlsUrl(result)) { + is ExtractionResult.Success -> resolved.data + is ExtractionResult.BadRequest -> return resolved + is ExtractionResult.Failure -> return resolved + } + return fetchAndRewrite(manifestUrl) + } + private suspend fun resolveHlsUrl(videoUrl: String): ExtractionResult { val result = streamService.getStreamInfo(videoUrl) + return resolveHlsUrl(result) + } + + private fun resolveHlsUrl(result: ExtractionResult): ExtractionResult { if (result is ExtractionResult.BadRequest) return result if (result !is ExtractionResult.Success) return ExtractionResult.Failure("No HLS stream available for this video") val hls = result.data.hlsUrl diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionHlsManifestService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionHlsManifestService.kt new file mode 100644 index 00000000..5a8e32fb --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionHlsManifestService.kt @@ -0,0 +1,48 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.ExtractionResult + +class YoutubeSessionHlsManifestService( + private val youtubeSessionService: YoutubeSessionService, + private val streamService: YoutubeSessionStreamService, + private val hlsManifestService: HlsManifestService, + private val tokenService: SignedHlsManifestTokenService, +) { + suspend fun hlsManifest(token: String, expectedUrl: String? = null): ExtractionResult = + when (val verified = tokenService.verify(token)) { + is SignedHlsManifestTokenResult.Valid -> { + if (expectedUrl != null && verified.token.videoUrl != expectedUrl) { + ExtractionResult.BadRequest("Invalid signed HLS manifest token") + } else { + hlsManifest(verified.token) + } + } + SignedHlsManifestTokenResult.Expired -> + ExtractionResult.BadRequest("Signed HLS manifest token expired") + SignedHlsManifestTokenResult.Invalid -> + ExtractionResult.BadRequest("Invalid signed HLS manifest token") + } + + suspend fun hlsManifestForUser(userId: String, videoUrl: String): ExtractionResult { + val credentials = youtubeSessionService.connectedCredentials(userId) + ?: return ExtractionResult.BadRequest(YOUTUBE_SESSION_RECONNECT_ERROR) + return hlsManifestForCredentials(credentials, videoUrl) + } + + private suspend fun hlsManifest(token: SignedHlsManifestToken): ExtractionResult { + val credentials = youtubeSessionService.connectedCredentials(token.userId) + ?: return ExtractionResult.BadRequest(YOUTUBE_SESSION_RECONNECT_ERROR) + if (credentials.fingerprint != token.fingerprint) { + return ExtractionResult.BadRequest(YOUTUBE_SESSION_RECONNECT_ERROR) + } + return hlsManifestForCredentials(credentials, token.videoUrl) + } + + private suspend fun hlsManifestForCredentials( + credentials: YoutubeSessionCredentials, + videoUrl: String, + ): ExtractionResult = + hlsManifestService.hlsManifestFromStreamInfo( + streamService.getStreamInfoForCredentials(credentials, videoUrl) + ) +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStreamService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStreamService.kt index 360a5c23..bdeb0f33 100644 --- a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStreamService.kt +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStreamService.kt @@ -10,23 +10,47 @@ class YoutubeSessionStreamService( private val streamService: StreamService, private val youtubeSessionService: YoutubeSessionService, private val cache: CacheService, + private val tokenService: SignedHlsManifestTokenService, ) { suspend fun getStreamInfo(userId: String, url: String): ExtractionResult? { if (!isYoutubeUrl(url)) return null val credentials = youtubeSessionService.connectedCredentials(userId) ?: return null + val result = getStreamInfoForCredentials(credentials, url) + return when (result) { + is ExtractionResult.Success -> + ExtractionResult.Success(result.data.withSignedHlsUrl(credentials, url)) + is ExtractionResult.BadRequest -> result + is ExtractionResult.Failure -> result + } + } + + suspend fun getStreamInfoForCredentials( + credentials: YoutubeSessionCredentials, + url: String, + ): ExtractionResult { val result = authenticatedCache(credentials, url) if (result is ExtractionResult.Success) { - youtubeSessionService.markUsed(userId) + youtubeSessionService.markUsed(credentials.userId) return result } if (requiresReconnect(result)) { - youtubeSessionService.markNeedsReconnect(userId) + youtubeSessionService.markNeedsReconnect(credentials.userId) return ExtractionResult.BadRequest(YOUTUBE_SESSION_RECONNECT_ERROR) } - youtubeSessionService.markUsed(userId) + youtubeSessionService.markUsed(credentials.userId) return result } + private fun StreamResponse.withSignedHlsUrl( + credentials: YoutubeSessionCredentials, + url: String, + ): StreamResponse = + if (hlsUrl.isBlank()) { + this + } else { + copy(hlsUrl = tokenService.createPath(credentials.userId, url, credentials.fingerprint)) + } + private suspend fun authenticatedCache( credentials: YoutubeSessionCredentials, url: String, From e532df14f721a90dcdf44af0179dbf913868ff23 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 12:32:28 +0200 Subject: [PATCH 14/15] test: cover authenticated hls manifests --- .../server/ManifestHlsAuthRoutesTest.kt | 99 +++++++++++++++++++ .../SignedHlsManifestTokenServiceTest.kt | 38 +++++++ .../server/StreamRoutesYoutubeSessionTest.kt | 11 ++- .../server/YoutubeSessionStreamServiceTest.kt | 35 ++++++- 4 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 src/test/kotlin/dev/typetype/server/ManifestHlsAuthRoutesTest.kt create mode 100644 src/test/kotlin/dev/typetype/server/SignedHlsManifestTokenServiceTest.kt diff --git a/src/test/kotlin/dev/typetype/server/ManifestHlsAuthRoutesTest.kt b/src/test/kotlin/dev/typetype/server/ManifestHlsAuthRoutesTest.kt new file mode 100644 index 00000000..fe5d7ca8 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/ManifestHlsAuthRoutesTest.kt @@ -0,0 +1,99 @@ +package dev.typetype.server + +import dev.typetype.server.cache.CacheService +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.routes.manifestRoutes +import dev.typetype.server.services.AuthService +import dev.typetype.server.services.CachedManifestService +import dev.typetype.server.services.CachedNativeManifestService +import dev.typetype.server.services.HlsManifestService +import dev.typetype.server.services.ManifestService +import dev.typetype.server.services.NativeManifestService +import dev.typetype.server.services.SignedHlsManifestCookie +import dev.typetype.server.services.StreamService +import dev.typetype.server.services.YoutubeSessionHlsManifestService +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +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.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import io.mockk.coEvery +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ManifestHlsAuthRoutesTest { + private val streamService: StreamService = mockk() + private val cache = InMemoryHlsAuthCache() + private val manifestService = CachedManifestService(ManifestService(streamService), cache) + private val nativeManifestService = CachedNativeManifestService(NativeManifestService(), cache) + private val hlsManifestService: HlsManifestService = mockk() + private val youtubeSessionHlsManifestService: YoutubeSessionHlsManifestService = mockk() + + @Test + fun `GET streams hls-manifest uses signed token`() = testApplication { + coEvery { youtubeSessionHlsManifestService.hlsManifest("signed") } returns + ExtractionResult.Success("#EXTM3U") + installRoutes() + val response = client.get("/streams/hls-manifest?token=signed") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("no-store", response.headers[HttpHeaders.CacheControl]) + assertEquals("#EXTM3U", response.bodyAsText()) + } + + @Test + fun `GET streams hls-manifest uses bearer session`() = testApplication { + coEvery { + youtubeSessionHlsManifestService.hlsManifestForUser(TEST_USER_ID, "https://youtube.com/watch?v=test") + } returns ExtractionResult.Success("#EXTM3U") + installRoutes() + val response = client.get("/streams/hls-manifest?url=https://youtube.com/watch?v=test") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + } + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("no-store", response.headers[HttpHeaders.CacheControl]) + assertEquals("#EXTM3U", response.bodyAsText()) + } + + @Test + fun `GET streams hls-manifest uses signed cookie for media request`() = testApplication { + val url = "https://youtube.com/watch?v=test" + coEvery { youtubeSessionHlsManifestService.hlsManifest("signed", url) } returns + ExtractionResult.Success("#EXTM3U") + installRoutes() + val response = client.get("/streams/hls-manifest?url=$url") { + headers.append(HttpHeaders.Cookie, "${SignedHlsManifestCookie.name(url)}=signed") + } + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("no-store", response.headers[HttpHeaders.CacheControl]) + assertEquals("#EXTM3U", response.bodyAsText()) + } + + private fun ApplicationTestBuilder.installRoutes(): Unit { + application { + install(ContentNegotiation) { json() } + routing { + manifestRoutes( + manifestService, + nativeManifestService, + hlsManifestService, + youtubeSessionHlsManifestService, + AuthService.fixed(TEST_USER_ID), + ) + } + } + } +} + +private class InMemoryHlsAuthCache : CacheService { + private val map = mutableMapOf() + override suspend fun get(key: String): String? = map[key] + override suspend fun set(key: String, value: String, ttlSeconds: Long) { map[key] = value } + override suspend fun delete(key: String) { map.remove(key) } +} diff --git a/src/test/kotlin/dev/typetype/server/SignedHlsManifestTokenServiceTest.kt b/src/test/kotlin/dev/typetype/server/SignedHlsManifestTokenServiceTest.kt new file mode 100644 index 00000000..3eec1114 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/SignedHlsManifestTokenServiceTest.kt @@ -0,0 +1,38 @@ +package dev.typetype.server + +import dev.typetype.server.services.SignedHlsManifestTokenResult +import dev.typetype.server.services.SignedHlsManifestTokenService +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SignedHlsManifestTokenServiceTest { + @Test + fun `token verifies expected payload`() { + val service = SignedHlsManifestTokenService("secret", nowMillis = { 1_000L }) + val token = service.createToken("user-1", "https://youtube.com/watch?v=test", "fingerprint") + val result = service.verify(token) + assertTrue(result is SignedHlsManifestTokenResult.Valid) + val payload = (result as SignedHlsManifestTokenResult.Valid).token + assertEquals("user-1", payload.userId) + assertEquals("https://youtube.com/watch?v=test", payload.videoUrl) + assertEquals("fingerprint", payload.fingerprint) + assertEquals(901_000L, payload.expiresAt) + } + + @Test + fun `token rejects tampered signature`() { + val service = SignedHlsManifestTokenService("secret", nowMillis = { 1_000L }) + val token = service.createToken("user-1", "https://youtube.com/watch?v=test", "fingerprint") + assertEquals(SignedHlsManifestTokenResult.Invalid, service.verify("${token.dropLast(1)}x")) + } + + @Test + fun `token expires`() { + var now = 1_000L + val service = SignedHlsManifestTokenService("secret", nowMillis = { now }) + val token = service.createToken("user-1", "https://youtube.com/watch?v=test", "fingerprint") + now = 901_000L + assertEquals(SignedHlsManifestTokenResult.Expired, service.verify(token)) + } +} diff --git a/src/test/kotlin/dev/typetype/server/StreamRoutesYoutubeSessionTest.kt b/src/test/kotlin/dev/typetype/server/StreamRoutesYoutubeSessionTest.kt index 664a5ac9..b8135119 100644 --- a/src/test/kotlin/dev/typetype/server/StreamRoutesYoutubeSessionTest.kt +++ b/src/test/kotlin/dev/typetype/server/StreamRoutesYoutubeSessionTest.kt @@ -1,6 +1,7 @@ package dev.typetype.server import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.services.SignedHlsManifestCookie import dev.typetype.server.routes.streamRoutes import dev.typetype.server.services.AuthService import dev.typetype.server.services.StreamService @@ -44,7 +45,10 @@ class StreamRoutesYoutubeSessionTest { ) application { testRoutes { _, _ -> - ExtractionResult.Success(testStreamResponse(videoOnlyStreams = emptyList(), audioStreams = emptyList()).copy(hlsUrl = "https://manifest.googlevideo.com/hls.m3u8")) + ExtractionResult.Success( + testStreamResponse(videoOnlyStreams = emptyList(), audioStreams = emptyList()) + .copy(hlsUrl = "/streams/hls-manifest?token=signed-hls") + ) } } val response = client.get("/streams?url=https://youtube.com/watch?v=test") { @@ -53,7 +57,10 @@ class StreamRoutesYoutubeSessionTest { assertEquals(HttpStatusCode.OK, response.status) assertEquals("no-store", response.headers[HttpHeaders.CacheControl]) val body = response.bodyAsText() - assertTrue(body.contains("\"hlsUrl\":\"https://manifest.googlevideo.com/hls.m3u8\"")) + assertTrue(body.contains("\"hlsUrl\":\"/streams/hls-manifest?token=signed-hls\"")) + assertTrue(response.headers.getAll(HttpHeaders.SetCookie).orEmpty().any { + it.startsWith("${SignedHlsManifestCookie.name("https://youtube.com/watch?v=test")}=signed-hls") + }) assertTrue(body.contains("\"videoOnlyStreams\":[")) assertTrue(body.contains("\"audioStreams\":[")) } diff --git a/src/test/kotlin/dev/typetype/server/YoutubeSessionStreamServiceTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeSessionStreamServiceTest.kt index daa5688b..e33bea67 100644 --- a/src/test/kotlin/dev/typetype/server/YoutubeSessionStreamServiceTest.kt +++ b/src/test/kotlin/dev/typetype/server/YoutubeSessionStreamServiceTest.kt @@ -3,6 +3,8 @@ package dev.typetype.server import dev.typetype.server.models.ExtractionResult import dev.typetype.server.models.StreamResponse import dev.typetype.server.models.YoutubeSessionCompleteRequest +import dev.typetype.server.services.SignedHlsManifestTokenResult +import dev.typetype.server.services.SignedHlsManifestTokenService import dev.typetype.server.services.StreamService import dev.typetype.server.services.YoutubeSessionCompleteResult import dev.typetype.server.services.YoutubeSessionCrypto @@ -17,6 +19,10 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test class YoutubeSessionStreamServiceTest { + private val tokenService = SignedHlsManifestTokenService( + "test-youtube-session-key-32-bytes", + nowMillis = { 1_000L }, + ) private val youtubeSessionService = YoutubeSessionService( YoutubeSessionCrypto.fromSecret("test-youtube-session-key-32-bytes") ) @@ -32,7 +38,7 @@ class YoutubeSessionStreamServiceTest { @Test fun `non YouTube url does not use authenticated extraction`() = runBlocking { - val service = YoutubeSessionStreamService(failingStreamService(), youtubeSessionService, FakeCacheService()) + val service = testService(failingStreamService()) assertNull(service.getStreamInfo(TEST_USER_ID, "https://example.com/watch?v=test")) } @@ -43,6 +49,7 @@ class YoutubeSessionStreamServiceTest { failingStreamService("No suitable stream"), youtubeSessionService, FakeCacheService(), + tokenService, ) val result = service.getStreamInfo(TEST_USER_ID, "https://youtube.com/watch?v=test") assertTrue(result is ExtractionResult.BadRequest) @@ -59,12 +66,33 @@ class YoutubeSessionStreamServiceTest { return ExtractionResult.Success(testStreamResponse()) } } - val service = YoutubeSessionStreamService(stream, youtubeSessionService, FakeCacheService()) + val service = testService(stream) assertTrue(service.getStreamInfo(TEST_USER_ID, "https://youtube.com/watch?v=test") is ExtractionResult.Success) assertTrue(service.getStreamInfo(TEST_USER_ID, "https://youtube.com/watch?v=test") is ExtractionResult.Success) assertEquals(1, calls) } + @Test + fun `successful authenticated extraction signs hls url`() = runBlocking { + connectYoutubeSession() + val stream = object : StreamService { + override suspend fun getStreamInfo(url: String): ExtractionResult = + ExtractionResult.Success( + testStreamResponse().copy( + hlsUrl = "https://manifest.googlevideo.com/api/manifest/hls_variant/file/index.m3u8" + ) + ) + } + val result = testService(stream).getStreamInfo(TEST_USER_ID, "https://youtube.com/watch?v=test") + assertTrue(result is ExtractionResult.Success) + val hlsUrl = (result as ExtractionResult.Success).data.hlsUrl + assertTrue(hlsUrl.startsWith("/streams/hls-manifest?token=")) + val token = hlsUrl.substringAfter("token=") + val verified = tokenService.verify(token) + assertTrue(verified is SignedHlsManifestTokenResult.Valid) + assertEquals("https://youtube.com/watch?v=test", (verified as SignedHlsManifestTokenResult.Valid).token.videoUrl) + } + private suspend fun connectYoutubeSession() { val pairing = youtubeSessionService.createPairing(TEST_USER_ID) val result = youtubeSessionService.complete( @@ -81,4 +109,7 @@ class YoutubeSessionStreamServiceTest { override suspend fun getStreamInfo(url: String): ExtractionResult = ExtractionResult.Failure(message) } + + private fun testService(stream: StreamService): YoutubeSessionStreamService = + YoutubeSessionStreamService(stream, youtubeSessionService, FakeCacheService(), tokenService) } From 623ea8c3480093f12a26f9a7970caddd61177169 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 18 Jun 2026 14:08:06 +0200 Subject: [PATCH 15/15] fix: replace progress position on put --- .../typetype/server/services/ProgressService.kt | 8 +++----- .../dev/typetype/server/ProgressRoutesTest.kt | 15 +++++++++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/dev/typetype/server/services/ProgressService.kt b/src/main/kotlin/dev/typetype/server/services/ProgressService.kt index 9c022ee6..2e5e73b0 100644 --- a/src/main/kotlin/dev/typetype/server/services/ProgressService.kt +++ b/src/main/kotlin/dev/typetype/server/services/ProgressService.kt @@ -27,22 +27,20 @@ class ProgressService { suspend fun upsert(userId: String, videoUrl: String, position: Long): ProgressItem { val now = System.currentTimeMillis() val safePosition = position.coerceAtLeast(0L) - val current = get(userId, videoUrl)?.position ?: 0L - val next = if (safePosition == 0L && current > 0L) current else maxOf(current, safePosition) DatabaseFactory.query { val updated = ProgressTable.update({ (ProgressTable.videoUrl eq videoUrl) and (ProgressTable.userId eq userId) }) { - it[ProgressTable.position] = next + it[ProgressTable.position] = safePosition it[updatedAt] = now } if (updated == 0) { ProgressTable.insert { it[ProgressTable.userId] = userId it[ProgressTable.videoUrl] = videoUrl - it[ProgressTable.position] = next + it[ProgressTable.position] = safePosition it[updatedAt] = now } } } - return ProgressItem(videoUrl = videoUrl, position = next, updatedAt = now) + return ProgressItem(videoUrl = videoUrl, position = safePosition, updatedAt = now) } } diff --git a/src/test/kotlin/dev/typetype/server/ProgressRoutesTest.kt b/src/test/kotlin/dev/typetype/server/ProgressRoutesTest.kt index 9a23bfe7..13f7cbbb 100644 --- a/src/test/kotlin/dev/typetype/server/ProgressRoutesTest.kt +++ b/src/test/kotlin/dev/typetype/server/ProgressRoutesTest.kt @@ -85,15 +85,22 @@ class ProgressRoutesTest { } @Test - fun `PUT progress keeps previous value when payload resets to zero`() = withApp { + fun `PUT progress replaces previous value with lower value and zero`() = withApp { service.upsert(TEST_USER_ID, "https://yt.com/v?v=test", 12000L) - val response = client.put("/progress/https%3A%2F%2Fyt.com%2Fv%3Fv%3Dtest") { + val lowerResponse = client.put("/progress/https%3A%2F%2Fyt.com%2Fv%3Fv%3Dtest") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"position":1234}""") + } + assertEquals(HttpStatusCode.OK, lowerResponse.status) + assertTrue(lowerResponse.bodyAsText().contains("\"position\":1234")) + val zeroResponse = client.put("/progress/https%3A%2F%2Fyt.com%2Fv%3Fv%3Dtest") { headers.append(HttpHeaders.Authorization, "Bearer test-jwt") headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) setBody("""{"position":0}""") } - assertEquals(HttpStatusCode.OK, response.status) - assertTrue(response.bodyAsText().contains("\"position\":12000")) + assertEquals(HttpStatusCode.OK, zeroResponse.status) + assertTrue(zeroResponse.bodyAsText().contains("\"position\":0")) } @Test