diff --git a/src/main/kotlin/dev/typetype/server/Application.kt b/src/main/kotlin/dev/typetype/server/Application.kt index 942cded..53eaf63 100644 --- a/src/main/kotlin/dev/typetype/server/Application.kt +++ b/src/main/kotlin/dev/typetype/server/Application.kt @@ -3,39 +3,27 @@ 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.blockedRoutes 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.favoritesRoutes -import dev.typetype.server.routes.historyRoutes -import dev.typetype.server.routes.homeRecommendationRoutes import dev.typetype.server.routes.manifestRoutes import dev.typetype.server.routes.nicoVideoProxyRoutes -import dev.typetype.server.routes.playlistRoutes -import dev.typetype.server.routes.profileRoutes -import dev.typetype.server.routes.progressRoutes import dev.typetype.server.routes.proxyRoutes -import dev.typetype.server.routes.restoreRoutes import dev.typetype.server.routes.storyboardProxyRoutes -import dev.typetype.server.routes.searchHistoryRoutes -import dev.typetype.server.routes.recommendationFeedbackRoutes -import dev.typetype.server.routes.recommendationEventsRoutes import dev.typetype.server.routes.searchRoutes -import dev.typetype.server.routes.settingsRoutes import dev.typetype.server.routes.streamRoutes -import dev.typetype.server.routes.subscriptionFeedRoutes -import dev.typetype.server.routes.subscriptionsRoutes 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.watchLaterRoutes +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 import dev.typetype.server.services.AvatarService @@ -73,6 +61,7 @@ fun Application.module() { val avatarService = AvatarService() val gitHubIssueService = GitHubIssueService() val adminSettingsService = AdminSettingsService() + val activeSessionService = ActiveSessionService(adminSettingsService) val instanceService = InstanceService(authService, adminSettingsService) val restoreService = PipePipeBackupImporterService() @@ -112,6 +101,8 @@ fun Application.module() { downloaderGatewayRoutes(downloaderGatewayService) authRoutes(authService, passwordResetService, profileService, adminSettingsService) 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) } diff --git a/src/main/kotlin/dev/typetype/server/db/DatabaseSessionAuthMigration.kt b/src/main/kotlin/dev/typetype/server/db/DatabaseSessionAuthMigration.kt index bdba576..c579c16 100644 --- a/src/main/kotlin/dev/typetype/server/db/DatabaseSessionAuthMigration.kt +++ b/src/main/kotlin/dev/typetype/server/db/DatabaseSessionAuthMigration.kt @@ -13,6 +13,7 @@ object DatabaseSessionAuthMigration { exec("ALTER TABLE admin_settings ADD COLUMN IF NOT EXISTS logo_url TEXT") exec("ALTER TABLE admin_settings ADD COLUMN IF NOT EXISTS banner_url TEXT") exec("ALTER TABLE admin_settings ADD COLUMN IF NOT EXISTS min_android_client_version TEXT") + exec("ALTER TABLE admin_settings ADD COLUMN IF NOT EXISTS active_sessions_enabled BOOLEAN NOT NULL DEFAULT false") exec("UPDATE sessions SET created_at = CASE WHEN created_at = 0 THEN expires_at - 86400000 ELSE created_at END") exec("CREATE UNIQUE INDEX IF NOT EXISTS sessions_refresh_token_hash_unique ON sessions (refresh_token_hash)") } 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 83e197b..ea0a74a 100644 --- a/src/main/kotlin/dev/typetype/server/db/tables/AdminSettingsTable.kt +++ b/src/main/kotlin/dev/typetype/server/db/tables/AdminSettingsTable.kt @@ -13,5 +13,6 @@ object AdminSettingsTable : Table("admin_settings") { val allowRegistration = bool("allow_registration").default(true) val allowGuest = bool("allow_guest").default(true) val forceEmailVerification = bool("force_email_verification").default(false) + val activeSessionsEnabled = bool("active_sessions_enabled").default(false) override val primaryKey = PrimaryKey(id) } diff --git a/src/main/kotlin/dev/typetype/server/models/ActiveSessionItem.kt b/src/main/kotlin/dev/typetype/server/models/ActiveSessionItem.kt new file mode 100644 index 0000000..ef3b25c --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/ActiveSessionItem.kt @@ -0,0 +1,20 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class ActiveSessionItem( + val id: String, + val userId: String?, + val username: String?, + val clientName: String? = null, + val clientVersion: String? = null, + val deviceId: String? = null, + val deviceName: String? = null, + val deviceType: String? = null, + val userAgent: String? = null, + val remoteAddress: String? = null, + val lastActivityAt: Long, + val lastPlaybackAt: Long? = null, + val nowPlaying: ActiveSessionNowPlayingItem? = null, +) diff --git a/src/main/kotlin/dev/typetype/server/models/ActiveSessionNowPlayingItem.kt b/src/main/kotlin/dev/typetype/server/models/ActiveSessionNowPlayingItem.kt new file mode 100644 index 0000000..5505a55 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/ActiveSessionNowPlayingItem.kt @@ -0,0 +1,15 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class ActiveSessionNowPlayingItem( + val videoUrl: String, + val title: String, + val thumbnail: String? = null, + val channelName: String? = null, + val positionMs: Long, + val durationMs: Long? = null, + val paused: Boolean, + val updatedAt: Long, +) diff --git a/src/main/kotlin/dev/typetype/server/models/AdminSettingsItem.kt b/src/main/kotlin/dev/typetype/server/models/AdminSettingsItem.kt index b6ff62a..be78be1 100644 --- a/src/main/kotlin/dev/typetype/server/models/AdminSettingsItem.kt +++ b/src/main/kotlin/dev/typetype/server/models/AdminSettingsItem.kt @@ -13,4 +13,5 @@ data class AdminSettingsItem( val allowRegistration: Boolean = true, val allowGuest: Boolean = true, val forceEmailVerification: Boolean = false, + val activeSessionsEnabled: Boolean = false, ) diff --git a/src/main/kotlin/dev/typetype/server/models/SessionActivityRequest.kt b/src/main/kotlin/dev/typetype/server/models/SessionActivityRequest.kt new file mode 100644 index 0000000..49f4a2b --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/SessionActivityRequest.kt @@ -0,0 +1,12 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class SessionActivityRequest( + val clientName: String? = null, + val clientVersion: String? = null, + val deviceId: String? = null, + val deviceName: String? = null, + val deviceType: String? = null, +) diff --git a/src/main/kotlin/dev/typetype/server/models/SessionPlaybackProgressRequest.kt b/src/main/kotlin/dev/typetype/server/models/SessionPlaybackProgressRequest.kt new file mode 100644 index 0000000..9894790 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/SessionPlaybackProgressRequest.kt @@ -0,0 +1,19 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class SessionPlaybackProgressRequest( + val clientName: String? = null, + val clientVersion: String? = null, + val deviceId: String? = null, + val deviceName: String? = null, + val deviceType: String? = null, + val videoUrl: String? = null, + val title: String? = null, + val thumbnail: String? = null, + val channelName: String? = null, + val positionMs: Long = 0, + val durationMs: Long? = null, + val paused: Boolean = false, +) diff --git a/src/main/kotlin/dev/typetype/server/models/SessionPlaybackStartRequest.kt b/src/main/kotlin/dev/typetype/server/models/SessionPlaybackStartRequest.kt new file mode 100644 index 0000000..18f0943 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/SessionPlaybackStartRequest.kt @@ -0,0 +1,19 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class SessionPlaybackStartRequest( + val clientName: String? = null, + val clientVersion: String? = null, + val deviceId: String? = null, + val deviceName: String? = null, + val deviceType: String? = null, + val videoUrl: String, + val title: String, + val thumbnail: String? = null, + val channelName: String? = null, + val positionMs: Long = 0, + val durationMs: Long? = null, + val paused: Boolean = false, +) diff --git a/src/main/kotlin/dev/typetype/server/models/SessionPlaybackStopRequest.kt b/src/main/kotlin/dev/typetype/server/models/SessionPlaybackStopRequest.kt new file mode 100644 index 0000000..e3bd4eb --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/SessionPlaybackStopRequest.kt @@ -0,0 +1,12 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class SessionPlaybackStopRequest( + val clientName: String? = null, + val clientVersion: String? = null, + val deviceId: String? = null, + val deviceName: String? = null, + val deviceType: String? = null, +) diff --git a/src/main/kotlin/dev/typetype/server/routes/AdminSessionRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/AdminSessionRoutes.kt new file mode 100644 index 0000000..d489fcb --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/routes/AdminSessionRoutes.kt @@ -0,0 +1,15 @@ +package dev.typetype.server.routes + +import dev.typetype.server.services.ActiveSessionService +import dev.typetype.server.services.AuthService +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get + +fun Route.adminSessionRoutes(authService: AuthService, activeSessionService: ActiveSessionService): Unit { + get("/admin/sessions") { + call.withAdminAuth(authService) { + call.respond(activeSessionService.list()) + } + } +} diff --git a/src/main/kotlin/dev/typetype/server/routes/SessionActivityRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/SessionActivityRoutes.kt new file mode 100644 index 0000000..cfeaec9 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/routes/SessionActivityRoutes.kt @@ -0,0 +1,60 @@ +package dev.typetype.server.routes + +import dev.typetype.server.models.ErrorResponse +import dev.typetype.server.models.SessionActivityRequest +import dev.typetype.server.models.SessionPlaybackProgressRequest +import dev.typetype.server.models.SessionPlaybackStartRequest +import dev.typetype.server.models.SessionPlaybackStopRequest +import dev.typetype.server.services.ActiveSessionService +import dev.typetype.server.services.AuthService +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.post + +fun Route.sessionActivityRoutes(authService: AuthService, activeSessionService: ActiveSessionService): Unit { + post("/sessions/activity") { + call.withJwtAuth(authService) { userId -> + val body = runCatching { call.receive() }.getOrElse { + return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request body")) + } + activeSessionService.reportActivity(userId, body, call.request.headers[HttpHeaders.UserAgent]) + call.respond(HttpStatusCode.NoContent) + } + } + + post("/sessions/playback/start") { + call.withJwtAuth(authService) { userId -> + val body = runCatching { call.receive() }.getOrElse { + return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request body")) + } + if (body.videoUrl.isBlank() || body.title.isBlank()) { + return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing now playing fields")) + } + activeSessionService.reportPlaybackStart(userId, body, call.request.headers[HttpHeaders.UserAgent]) + call.respond(HttpStatusCode.NoContent) + } + } + + post("/sessions/playback/progress") { + call.withJwtAuth(authService) { userId -> + val body = runCatching { call.receive() }.getOrElse { + return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request body")) + } + activeSessionService.reportPlaybackProgress(userId, body, call.request.headers[HttpHeaders.UserAgent]) + call.respond(HttpStatusCode.NoContent) + } + } + + post("/sessions/playback/stop") { + call.withJwtAuth(authService) { userId -> + val body = runCatching { call.receive() }.getOrElse { + return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request body")) + } + activeSessionService.reportPlaybackStop(userId, body, call.request.headers[HttpHeaders.UserAgent]) + call.respond(HttpStatusCode.NoContent) + } + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/ActiveSessionNowPlayingMapper.kt b/src/main/kotlin/dev/typetype/server/services/ActiveSessionNowPlayingMapper.kt new file mode 100644 index 0000000..da44b50 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/ActiveSessionNowPlayingMapper.kt @@ -0,0 +1,45 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.ActiveSessionNowPlayingItem +import dev.typetype.server.models.SessionPlaybackProgressRequest +import dev.typetype.server.models.SessionPlaybackStartRequest + +internal object ActiveSessionNowPlayingMapper { + fun fromStart(request: SessionPlaybackStartRequest, now: Long): ActiveSessionNowPlayingItem = ActiveSessionNowPlayingItem( + videoUrl = request.videoUrl.trim(), + title = request.title.trim(), + thumbnail = ActiveSessionStrings.text(request.thumbnail), + channelName = ActiveSessionStrings.text(request.channelName), + positionMs = request.positionMs.coerceAtLeast(0), + durationMs = request.durationMs?.coerceAtLeast(0), + paused = request.paused, + updatedAt = now, + ) + + fun fromProgress(current: ActiveSessionNowPlayingItem?, request: SessionPlaybackProgressRequest, now: Long): ActiveSessionNowPlayingItem? = + current?.copy( + videoUrl = ActiveSessionStrings.text(request.videoUrl) ?: current.videoUrl, + title = ActiveSessionStrings.text(request.title) ?: current.title, + thumbnail = ActiveSessionStrings.text(request.thumbnail) ?: current.thumbnail, + channelName = ActiveSessionStrings.text(request.channelName) ?: current.channelName, + positionMs = request.positionMs.coerceAtLeast(0), + durationMs = request.durationMs?.coerceAtLeast(0) ?: current.durationMs, + paused = request.paused, + updatedAt = now, + ) ?: request.toNowPlaying(now) + + private fun SessionPlaybackProgressRequest.toNowPlaying(now: Long): ActiveSessionNowPlayingItem? { + val normalizedUrl = ActiveSessionStrings.text(videoUrl) ?: return null + val normalizedTitle = ActiveSessionStrings.text(title) ?: return null + return ActiveSessionNowPlayingItem( + videoUrl = normalizedUrl, + title = normalizedTitle, + thumbnail = ActiveSessionStrings.text(thumbnail), + channelName = ActiveSessionStrings.text(channelName), + positionMs = positionMs.coerceAtLeast(0), + durationMs = durationMs?.coerceAtLeast(0), + paused = paused, + updatedAt = now, + ) + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/ActiveSessionRecord.kt b/src/main/kotlin/dev/typetype/server/services/ActiveSessionRecord.kt new file mode 100644 index 0000000..7ebbe0c --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/ActiveSessionRecord.kt @@ -0,0 +1,18 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.ActiveSessionNowPlayingItem + +internal data class ActiveSessionRecord( + val id: String, + val userId: String, + val username: String?, + val clientName: String?, + val clientVersion: String?, + val deviceId: String?, + val deviceName: String?, + val deviceType: String?, + val userAgent: String?, + val lastActivityAt: Long, + val lastPlaybackAt: Long?, + val nowPlaying: ActiveSessionNowPlayingItem?, +) diff --git a/src/main/kotlin/dev/typetype/server/services/ActiveSessionService.kt b/src/main/kotlin/dev/typetype/server/services/ActiveSessionService.kt new file mode 100644 index 0000000..60d00cd --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/ActiveSessionService.kt @@ -0,0 +1,111 @@ +package dev.typetype.server.services + +import dev.typetype.server.db.DatabaseFactory +import dev.typetype.server.db.tables.UsersTable +import dev.typetype.server.models.ActiveSessionItem +import dev.typetype.server.models.SessionActivityRequest +import dev.typetype.server.models.SessionPlaybackProgressRequest +import dev.typetype.server.models.SessionPlaybackStartRequest +import dev.typetype.server.models.SessionPlaybackStopRequest +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.selectAll +import java.util.concurrent.ConcurrentHashMap + +class ActiveSessionService( + private val adminSettingsService: AdminSettingsService, + private val nowProvider: () -> Long = System::currentTimeMillis, +) { + private val sessions = ConcurrentHashMap() + + suspend fun reportActivity(userId: String, request: SessionActivityRequest, userAgent: String?): Unit { + updateSession(userId, request.clientName, request.clientVersion, request.deviceId, request.deviceName, request.deviceType, userAgent, null, null) + } + + suspend fun reportPlaybackStart(userId: String, request: SessionPlaybackStartRequest, userAgent: String?): Unit { + val now = nowProvider() + val nowPlaying = ActiveSessionNowPlayingMapper.fromStart(request, now) + updateSession(userId, request.clientName, request.clientVersion, request.deviceId, request.deviceName, request.deviceType, userAgent, nowPlaying, now) + } + + suspend fun reportPlaybackProgress(userId: String, request: SessionPlaybackProgressRequest, userAgent: String?): Unit { + val now = nowProvider() + val id = sessionId(userId, request.deviceId, request.clientName) + val current = sessions[id]?.nowPlaying + val nowPlaying = ActiveSessionNowPlayingMapper.fromProgress(current, request, now) + updateSession(userId, request.clientName, request.clientVersion, request.deviceId, request.deviceName, request.deviceType, userAgent, nowPlaying, now) + } + + suspend fun reportPlaybackStop(userId: String, request: SessionPlaybackStopRequest, userAgent: String?): Unit { + updateSession(userId, request.clientName, request.clientVersion, request.deviceId, request.deviceName, request.deviceType, userAgent, null, nowProvider()) + } + + suspend fun list(): List { + if (!active()) return emptyList() + val now = nowProvider() + pruneExpired(now) + return sessions.values.sortedByDescending { it.lastActivityAt }.map { it.toItem() } + } + + fun clear(): Unit { + sessions.clear() + } + + private suspend fun updateSession( + userId: String, + clientName: String?, + clientVersion: String?, + deviceId: String?, + deviceName: String?, + deviceType: String?, + userAgent: String?, + nowPlaying: dev.typetype.server.models.ActiveSessionNowPlayingItem?, + lastPlaybackAt: Long?, + ) { + if (!active()) return + val now = nowProvider() + pruneExpired(now) + val id = sessionId(userId, deviceId, clientName) + sessions[id] = ActiveSessionRecord( + id = id, + userId = userId, + username = username(userId), + clientName = ActiveSessionStrings.text(clientName), + clientVersion = ActiveSessionStrings.text(clientVersion), + deviceId = ActiveSessionStrings.text(deviceId), + deviceName = ActiveSessionStrings.text(deviceName), + deviceType = ActiveSessionStrings.text(deviceType), + userAgent = ActiveSessionStrings.userAgent(userAgent), + lastActivityAt = now, + lastPlaybackAt = lastPlaybackAt, + nowPlaying = nowPlaying, + ) + } + + private suspend fun active(): Boolean { + val enabled = adminSettingsService.get().activeSessionsEnabled + if (!enabled) clear() + return enabled + } + + private fun pruneExpired(now: Long) { + sessions.entries.removeIf { now - it.value.lastActivityAt > INACTIVITY_TTL_MS } + } + + private suspend fun username(userId: String): String? { + if (userId.startsWith("guest:")) return null + return DatabaseFactory.query { + UsersTable.selectAll().where { UsersTable.id eq userId }.singleOrNull()?.let { + it[UsersTable.publicUsername] ?: it[UsersTable.name] + } + } + } + + private fun sessionId(userId: String, deviceId: String?, clientName: String?): String = + listOf(userId, ActiveSessionStrings.key(deviceId), ActiveSessionStrings.key(clientName)).joinToString(":") + + private fun ActiveSessionRecord.toItem(): ActiveSessionItem = ActiveSessionItem(id, userId, username, clientName, clientVersion, deviceId, deviceName, deviceType, userAgent, null, lastActivityAt, lastPlaybackAt, nowPlaying) + + companion object { + const val INACTIVITY_TTL_MS = 120_000L + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/ActiveSessionStrings.kt b/src/main/kotlin/dev/typetype/server/services/ActiveSessionStrings.kt new file mode 100644 index 0000000..eb52953 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/ActiveSessionStrings.kt @@ -0,0 +1,12 @@ +package dev.typetype.server.services + +internal object ActiveSessionStrings { + fun text(value: String?): String? = value?.trim()?.take(MAX_TEXT_LENGTH)?.takeIf { it.isNotEmpty() } + + fun userAgent(value: String?): String? = value?.trim()?.take(MAX_USER_AGENT_LENGTH)?.takeIf { it.isNotEmpty() } + + fun key(value: String?): String = text(value) ?: "unknown" + + private const val MAX_TEXT_LENGTH = 120 + private const val MAX_USER_AGENT_LENGTH = 200 +} diff --git a/src/main/kotlin/dev/typetype/server/services/AdminSettingsService.kt b/src/main/kotlin/dev/typetype/server/services/AdminSettingsService.kt index 12a2bb5..631bbc7 100644 --- a/src/main/kotlin/dev/typetype/server/services/AdminSettingsService.kt +++ b/src/main/kotlin/dev/typetype/server/services/AdminSettingsService.kt @@ -26,6 +26,7 @@ class AdminSettingsService { allowRegistration = it[AdminSettingsTable.allowRegistration], allowGuest = it[AdminSettingsTable.allowGuest], forceEmailVerification = it[AdminSettingsTable.forceEmailVerification], + activeSessionsEnabled = it[AdminSettingsTable.activeSessionsEnabled], ).normalized() } ?: AdminSettingsItem() } @@ -47,6 +48,7 @@ class AdminSettingsService { it[allowRegistration] = settings.allowRegistration it[allowGuest] = settings.allowGuest it[forceEmailVerification] = settings.forceEmailVerification + it[activeSessionsEnabled] = settings.activeSessionsEnabled } } else { AdminSettingsTable.insert { @@ -59,6 +61,7 @@ class AdminSettingsService { it[allowRegistration] = settings.allowRegistration it[allowGuest] = settings.allowGuest it[forceEmailVerification] = settings.forceEmailVerification + it[activeSessionsEnabled] = settings.activeSessionsEnabled } } } diff --git a/src/test/kotlin/dev/typetype/server/ActiveSessionAuthAndTtlTest.kt b/src/test/kotlin/dev/typetype/server/ActiveSessionAuthAndTtlTest.kt new file mode 100644 index 0000000..ab8a32e --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/ActiveSessionAuthAndTtlTest.kt @@ -0,0 +1,59 @@ +package dev.typetype.server + +import dev.typetype.server.models.AdminSettingsItem +import dev.typetype.server.models.SessionActivityRequest +import dev.typetype.server.routes.adminSessionRoutes +import dev.typetype.server.services.ActiveSessionService +import dev.typetype.server.services.AdminSettingsService +import dev.typetype.server.services.AuthService +import io.ktor.client.request.get +import io.ktor.client.request.header +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.testApplication +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ActiveSessionAuthAndTtlTest { + private val adminSettings = AdminSettingsService() + + companion object { + @BeforeAll + @JvmStatic + fun initDb(): Unit = TestDatabase.setup() + } + + @BeforeEach + fun clean(): Unit = TestDatabase.truncateAll() + + @Test + fun `admin sessions endpoint is admin only`(): Unit = testApplication { + insertActiveSessionUser(id = "user-id", role = "user", publicUsername = "plain") + val auth = AuthService.fixed("user-id") + application { + install(ContentNegotiation) { json() } + routing { adminSessionRoutes(auth, ActiveSessionService(adminSettings)) } + } + val response = client.get("/admin/sessions") { header(HttpHeaders.Authorization, "Bearer test-jwt") } + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun `inactive sessions expire after ttl`(): Unit = runBlocking { + var now = 1_000L + insertActiveSessionUser(publicUsername = "viewer") + adminSettings.upsert(AdminSettingsItem(activeSessionsEnabled = true)) + val service = ActiveSessionService(adminSettings) { now } + service.reportActivity(TEST_USER_ID, SessionActivityRequest(clientName = "web", deviceId = "device-1"), "UA") + assertEquals(1, service.list().size) + now += ActiveSessionService.INACTIVITY_TTL_MS + 1 + assertEquals(0, service.list().size) + } +} diff --git a/src/test/kotlin/dev/typetype/server/ActiveSessionRoutesTest.kt b/src/test/kotlin/dev/typetype/server/ActiveSessionRoutesTest.kt new file mode 100644 index 0000000..bec4035 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/ActiveSessionRoutesTest.kt @@ -0,0 +1,106 @@ +package dev.typetype.server + +import dev.typetype.server.models.AdminSettingsItem +import dev.typetype.server.routes.adminSessionRoutes +import dev.typetype.server.routes.sessionActivityRoutes +import dev.typetype.server.services.ActiveSessionService +import dev.typetype.server.services.AdminSettingsService +import dev.typetype.server.services.AuthService +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 kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ActiveSessionRoutesTest { + private val adminSettings = AdminSettingsService() + private val auth = AuthService.fixed(TEST_USER_ID) + + companion object { + @BeforeAll + @JvmStatic + fun initDb(): Unit = TestDatabase.setup() + } + + @BeforeEach + fun clean(): Unit = TestDatabase.truncateAll() + + private fun withApp(service: ActiveSessionService, block: suspend ApplicationTestBuilder.() -> Unit): Unit = testApplication { + application { + install(ContentNegotiation) { json() } + routing { + sessionActivityRoutes(auth, service) + adminSessionRoutes(auth, service) + } + } + block() + } + + @Test + fun `disabled setting makes reporting a no-op`(): Unit = withApp(ActiveSessionService(adminSettings)) { + insertActiveSessionUser() + val report = client.post("/sessions/playback/start") { + header(HttpHeaders.Authorization, "Bearer test-jwt") + header(HttpHeaders.UserAgent, "UA") + header("X-Real-IP", "192.168.1.42") + contentType(ContentType.Application.Json) + setBody(startBody()) + } + val list = client.get("/admin/sessions") { header(HttpHeaders.Authorization, "Bearer test-jwt") } + assertEquals(HttpStatusCode.NoContent, report.status) + assertEquals("[]", list.bodyAsText()) + } + + @Test + fun `enabled setting exposes active session without ip`(): Unit = withApp(ActiveSessionService(adminSettings)) { + insertActiveSessionUser(publicUsername = "viewer") + adminSettings.upsert(AdminSettingsItem(activeSessionsEnabled = true)) + client.post("/sessions/playback/start") { + header(HttpHeaders.Authorization, "Bearer test-jwt") + header(HttpHeaders.UserAgent, "A".repeat(250)) + header("X-Real-IP", "192.168.1.42") + contentType(ContentType.Application.Json) + setBody(startBody()) + } + val root = Json.parseToJsonElement(client.get("/admin/sessions") { header(HttpHeaders.Authorization, "Bearer test-jwt") }.bodyAsText()).jsonArray + val item = root.first().jsonObject + assertEquals(1, root.size) + assertEquals("viewer", item["username"]?.jsonPrimitive?.contentOrNull) + assertEquals(200, item["userAgent"]?.jsonPrimitive?.contentOrNull?.length) + assertEquals(null, item["remoteAddress"]?.jsonPrimitive?.contentOrNull) + assertEquals("Video", item["nowPlaying"]?.jsonObject?.get("title")?.jsonPrimitive?.contentOrNull) + } + + @Test + fun `stop clears now playing but keeps session active`(): Unit = withApp(ActiveSessionService(adminSettings)) { + insertActiveSessionUser() + adminSettings.upsert(AdminSettingsItem(activeSessionsEnabled = true)) + client.post("/sessions/playback/start") { header(HttpHeaders.Authorization, "Bearer test-jwt"); contentType(ContentType.Application.Json); setBody(startBody()) } + client.post("/sessions/playback/stop") { header(HttpHeaders.Authorization, "Bearer test-jwt"); contentType(ContentType.Application.Json); setBody(deviceBody()) } + val item = Json.parseToJsonElement(client.get("/admin/sessions") { header(HttpHeaders.Authorization, "Bearer test-jwt") }.bodyAsText()).jsonArray.first().jsonObject + assertEquals(null, item["nowPlaying"]?.jsonPrimitive?.contentOrNull) + } + + private fun startBody(): String = """{"clientName":"web","deviceId":"device-1","videoUrl":"https://yt.test/watch?v=1","title":"Video","positionMs":1000,"paused":false}""" + + private fun deviceBody(): String = """{"clientName":"web","deviceId":"device-1"}""" +} diff --git a/src/test/kotlin/dev/typetype/server/ActiveSessionTestFixtures.kt b/src/test/kotlin/dev/typetype/server/ActiveSessionTestFixtures.kt new file mode 100644 index 0000000..ff96fa8 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/ActiveSessionTestFixtures.kt @@ -0,0 +1,23 @@ +package dev.typetype.server + +import dev.typetype.server.db.tables.UsersTable +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.transactions.transaction + +fun insertActiveSessionUser( + id: String = TEST_USER_ID, + role: String = "admin", + name: String = "Admin", + publicUsername: String? = "admin", +): Unit = transaction { + UsersTable.insert { + it[UsersTable.id] = id + it[email] = "$id@test.local" + it[passwordHash] = "hash" + it[UsersTable.name] = name + it[UsersTable.role] = role + it[UsersTable.publicUsername] = publicUsername + it[createdAt] = 10L + it[updatedAt] = 10L + } +}