tag(功能):功能跑通

This commit is contained in:
OrangeRoll
2026-08-07 13:15:26 +08:00
parent 9e0387bf94
commit 3d1c6a6c76
19 changed files with 892 additions and 418 deletions
+197 -5
View File
@@ -1,13 +1,205 @@
package top.OrangeRoll
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.request.receive
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.serialization.kotlinx.json.json
import java.nio.charset.StandardCharsets
import java.time.Instant
import java.util.Base64
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
fun Application.configureRouting() {
install(ContentNegotiation) {
json()
}
install(CORS) {
val configuredOrigins = this@configureRouting.environment.config
.propertyOrNull("cors.allowedOrigins")
?.getString()
?.split(",")
?.map(String::trim)
?.filter(String::isNotEmpty)
.orEmpty()
configuredOrigins.ifEmpty { listOf("http://localhost:5173") }.forEach { origin ->
val host = origin.removePrefix("http://").removePrefix("https://")
allowHost(
host = host,
schemes = listOf(if (origin.startsWith("https://")) "https" else "http"),
)
}
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Get)
allowHeader(HttpHeaders.ContentType)
}
routing {
get("/") {
call.respondText("Hello, World!")
get("/health") {
call.respond(HealthResponse(status = "ok"))
}
post("/api/rooms/{roomId}/token") {
val roomName = call.parameters["roomId"]?.trim().orEmpty()
if (!ROOM_NAME.matches(roomName)) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("roomId must contain 3-64 letters, numbers, underscores, or hyphens"),
)
return@post
}
val request = call.receive<TokenRequest>()
val role = runCatching { RoomRole.valueOf(request.role.lowercase()) }.getOrNull()
if (role == null) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("role must be host, publisher, or viewer"))
return@post
}
val participantName = request.participantName.trim()
if (participantName.length !in 1..64) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("participantName must be 1-64 characters"))
return@post
}
val apiKey = environment.config.propertyOrNull("livekit.apiKey")?.getString()
?: System.getenv("LIVEKIT_API_KEY")
val apiSecret = environment.config.propertyOrNull("livekit.apiSecret")?.getString()
?: System.getenv("LIVEKIT_API_SECRET")
val publicUrl = environment.config.propertyOrNull("livekit.publicUrl")?.getString()
?: System.getenv("LIVEKIT_PUBLIC_URL")
if (apiKey.isNullOrBlank() || apiSecret.isNullOrBlank() || publicUrl.isNullOrBlank()) {
application.log.error("LiveKit environment variables are not configured")
call.respond(HttpStatusCode.ServiceUnavailable, ErrorResponse("Video service is not configured"))
return@post
}
val identity = "dev-${UUID.randomUUID()}"
val token = LiveKitTokenIssuer(apiKey, apiSecret).issue(
roomName = roomName,
identity = identity,
participantName = participantName,
role = role,
)
call.respond(
TokenResponse(
roomName = roomName,
participantIdentity = identity,
participantName = participantName,
livekitUrl = publicUrl,
token = token,
role = role.name,
),
)
}
}
}
}
private val ROOM_NAME = Regex("[A-Za-z0-9_-]{3,64}")
@Serializable
data class HealthResponse(val status: String)
@Serializable
data class TokenRequest(
val participantName: String,
val role: String = "viewer",
)
@Serializable
data class TokenResponse(
val roomName: String,
val participantIdentity: String,
val participantName: String,
val livekitUrl: String,
val token: String,
val role: String,
)
@Serializable
data class ErrorResponse(val message: String)
private enum class RoomRole {
host,
publisher,
viewer,
}
/**
* Creates a standard LiveKit HS256 access token. The API secret remains exclusively on the server.
* Replace the development role supplied by the client with application JWT authorization before production.
*/
private class LiveKitTokenIssuer(
private val apiKey: String,
private val apiSecret: String,
) {
fun issue(
roomName: String,
identity: String,
participantName: String,
role: RoomRole,
): String {
val now = Instant.now().epochSecond
val canPublish = role == RoomRole.host || role == RoomRole.publisher
val payload = Json.encodeToString(
LiveKitClaims.serializer(),
LiveKitClaims(
iss = apiKey,
sub = identity,
name = participantName,
nbf = now,
exp = now + TOKEN_TTL_SECONDS,
video = VideoGrant(
roomJoin = true,
room = roomName,
canPublish = canPublish,
canSubscribe = true,
canPublishData = canPublish,
),
),
)
val header = base64Url("""{"alg":"HS256","typ":"JWT"}""")
val body = base64Url(payload)
val unsignedToken = "$header.$body"
val signature = Mac.getInstance("HmacSHA256")
.apply { init(SecretKeySpec(apiSecret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")) }
.doFinal(unsignedToken.toByteArray(StandardCharsets.UTF_8))
return "$unsignedToken.${Base64.getUrlEncoder().withoutPadding().encodeToString(signature)}"
}
private fun base64Url(value: String): String =
Base64.getUrlEncoder().withoutPadding().encodeToString(value.toByteArray(StandardCharsets.UTF_8))
private companion object {
const val TOKEN_TTL_SECONDS = 10 * 60L
}
}
@Serializable
private data class LiveKitClaims(
val iss: String,
val sub: String,
val name: String,
val nbf: Long,
val exp: Long,
val video: VideoGrant,
)
@Serializable
private data class VideoGrant(
val roomJoin: Boolean,
val room: String,
val canPublish: Boolean,
val canSubscribe: Boolean,
val canPublishData: Boolean,
)