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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 6 additions & 15 deletions src/main/kotlin/dev/typetype/server/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
20 changes: 20 additions & 0 deletions src/main/kotlin/dev/typetype/server/models/ActiveSessionItem.kt
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ data class AdminSettingsItem(
val allowRegistration: Boolean = true,
val allowGuest: Boolean = true,
val forceEmailVerification: Boolean = false,
val activeSessionsEnabled: Boolean = false,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
15 changes: 15 additions & 0 deletions src/main/kotlin/dev/typetype/server/routes/AdminSessionRoutes.kt
Original file line number Diff line number Diff line change
@@ -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())
}
}
}
Original file line number Diff line number Diff line change
@@ -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<SessionActivityRequest>() }.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<SessionPlaybackStartRequest>() }.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<SessionPlaybackProgressRequest>() }.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<SessionPlaybackStopRequest>() }.getOrElse {
return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request body"))
}
activeSessionService.reportPlaybackStop(userId, body, call.request.headers[HttpHeaders.UserAgent])
call.respond(HttpStatusCode.NoContent)
}
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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?,
)
Loading