|
|
|
@@ -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,
|
|
|
|
|
)
|
|
|
|
|