Files
ScreenShare/apps/ScreenShareApi/src/main/kotlin/Routing.kt
T

272 lines
13 KiB
Kotlin

package top.OrangeRoll
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.auth.authenticate
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.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
import org.mindrot.jbcrypt.BCrypt
fun Application.configureRouting() {
install(ContentNegotiation) { json() }
install(StatusPages) {
exception<ApiException> { call, error ->
call.respond(HttpStatusCode.fromValue(error.status), ErrorResponse(error.message))
}
}
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 ->
allowHost(
host = origin.removePrefix("http://").removePrefix("https://"),
schemes = listOf(if (origin.startsWith("https://")) "https" else "http"),
)
}
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Get)
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Authorization)
}
routing {
get("/health") { call.respond(HealthResponse("ok")) }
post("/api/auth/login") {
val request = call.receive<LoginRequest>()
val user = UserRepository.findByUsername(request.username.normalizedUsername())
?.takeIf { BCrypt.checkpw(request.password, it.passwordHash) }
?: throw ApiException("用户名或密码错误", 401)
call.respond(authResponse(user))
}
post("/api/auth/register") {
val request = call.receive<RegisterRequest>()
val username = request.username.normalizedUsername()
validateCredentials(username, request.password, request.displayName)
if (UserRepository.findByUsername(username) != null) {
throw ApiException("用户名已存在", 409)
}
val user = UserRepository.register(
inviteCode = request.inviteCode.trim(),
username = username,
password = request.password,
displayName = request.displayName.trim(),
)
call.respond(HttpStatusCode.Created, authResponse(user))
}
authenticate("auth-jwt") {
get("/api/auth/me") {
call.respond(UserResponse(call.requireCurrentUser()))
}
post("/api/admin/invitations") {
val admin = call.requireAdministrator()
val request = call.receive<CreateInvitationRequest>()
val type = runCatching { InvitationType.valueOf(request.type.lowercase()) }.getOrNull()
?: throw ApiException("邀请码类型必须是 user 或 admin", 400)
if (request.expiresInHours !in 1..720) {
throw ApiException("邀请码有效期必须在 1 到 720 小时之间", 400)
}
val expiresAt = Instant.now().plusSeconds(request.expiresInHours * 3600L)
val code = InvitationRepository.create(admin.id, type, expiresAt)
call.respond(
HttpStatusCode.Created,
InvitationResponse(code, type.name, expiresAt.toString()),
)
}
post("/api/rooms") {
val user = call.requireCurrentUser()
val title = call.receive<CreateRoomRequest>().title.trim()
if (title.length !in 1..128) throw ApiException("房间标题必须为 1-128 个字符", 400)
if (RoomRepository.findByTitle(title) != null) throw ApiException("该房间标题已存在", 409)
call.respond(HttpStatusCode.Created, RoomResponse(RoomRepository.create(user.id, title), RoomRole.host))
}
get("/api/rooms/{roomId}") {
val user = call.requireCurrentUser()
val room = RoomRepository.find(call.roomId()) ?: throw ApiException("房间不存在", 404)
val membership = RoomRepository.member(room.id, user.id)
if (membership == null && !user.isAdmin) throw ApiException("没有访问此房间的权限", 403)
call.respond(RoomResponse(room, membership?.role))
}
post("/api/rooms/{roomId}/join") {
val user = call.requireCurrentUser()
call.respond(RoomMemberResponse(RoomRepository.joinAsViewer(call.roomId(), user.id)))
}
post("/api/rooms/join-by-title") {
val user = call.requireCurrentUser()
val title = call.receive<FindRoomByTitleRequest>().title.trim()
if (title.isEmpty()) throw ApiException("请输入房间标题", 400)
val room = RoomRepository.findByTitle(title) ?: throw ApiException("未找到该房间", 404)
val membership = RoomRepository.joinAsViewer(room.id, user.id)
call.respond(RoomResponse(room, membership.role))
}
post("/api/rooms/{roomId}/members/{userId}/role") {
val requester = call.requireCurrentUser()
val roomId = call.roomId()
val requesterMembership = RoomRepository.member(roomId, requester.id)
if (requesterMembership?.role != RoomRole.host && !requester.isAdmin) {
throw ApiException("需要 host 或管理员权限", 403)
}
val targetUserId = call.parameters["userId"]?.let { runCatching { UUID.fromString(it) }.getOrNull() }
?: throw ApiException("userId 必须是 UUID", 400)
val role = runCatching { RoomRole.valueOf(call.receive<UpdateMemberRoleRequest>().role.lowercase()) }.getOrNull()
?: throw ApiException("角色必须是 host、publisher 或 viewer", 400)
call.respond(RoomMemberResponse(RoomRepository.updateRole(roomId, targetUserId, role)))
}
post("/api/rooms/{roomId}/token") {
val user = call.requireCurrentUser()
val room = RoomRepository.find(call.roomId()) ?: throw ApiException("房间不存在", 404)
val membership = RoomRepository.member(room.id, user.id)
?: throw ApiException("请先加入房间", 403)
val liveKit = liveKitSettings()
val participantIdentity = user.id.toString() + "-" + UUID.randomUUID()
val token = LiveKitTokenIssuer(liveKit.apiKey, liveKit.apiSecret).issue(
roomName = room.id.toString(),
identity = participantIdentity,
participantName = user.displayName,
role = membership.role,
)
call.respond(
TokenResponse(
roomName = room.id.toString(),
participantIdentity = participantIdentity,
participantName = user.displayName,
livekitUrl = liveKit.publicUrl,
token = token,
role = membership.role.name,
),
)
}
}
}
}
private fun Application.authResponse(user: User) =
AuthResponse(issueApplicationToken(user), UserResponse(user))
private fun io.ktor.server.application.ApplicationCall.requireCurrentUser(): User =
UserRepository.findById(currentUserId()) ?: throw ApiException("用户不存在", 401)
private fun io.ktor.server.application.ApplicationCall.requireAdministrator(): User =
requireCurrentUser().takeIf { it.isAdmin } ?: throw ApiException("需要管理员权限", 403)
private fun io.ktor.server.application.ApplicationCall.roomId(): UUID =
parameters["roomId"]?.let { runCatching { UUID.fromString(it) }.getOrNull() }
?: throw ApiException("roomId 必须是 UUID", 400)
private fun String.normalizedUsername(): String {
val normalized = trim().lowercase()
if (!Regex("[a-z0-9_-]{3,64}").matches(normalized)) {
throw ApiException("用户名只能包含 3-64 个小写字母、数字、下划线或连字符", 400)
}
return normalized
}
private fun validateCredentials(username: String, password: String, displayName: String) {
username.normalizedUsername()
if (password.length !in 8..128) throw ApiException("密码必须为 8-128 个字符", 400)
if (displayName.trim().length !in 1..64) throw ApiException("显示名称必须为 1-64 个字符", 400)
}
private fun Application.liveKitSettings(): LiveKitSettings {
val config = environment.config
fun value(path: String, variable: String): String =
config.propertyOrNull(path)?.getString()
?: System.getenv(variable)
?: throw ApiException("视频服务未配置", 503)
return LiveKitSettings(
value("livekit.apiKey", "LIVEKIT_API_KEY"),
value("livekit.apiSecret", "LIVEKIT_API_SECRET"),
value("livekit.publicUrl", "LIVEKIT_PUBLIC_URL"),
)
}
private data class LiveKitSettings(val apiKey: String, val apiSecret: String, val publicUrl: String)
class ApiException(override val message: String, val status: Int) : RuntimeException(message)
enum class RoomRole { host, publisher, viewer }
@Serializable data class HealthResponse(val status: String)
@Serializable data class ErrorResponse(val message: String)
@Serializable data class LoginRequest(val username: String, val password: String)
@Serializable data class RegisterRequest(val inviteCode: String, val username: String, val password: String, val displayName: String)
@Serializable data class CreateInvitationRequest(val type: String, val expiresInHours: Int = 168)
@Serializable data class CreateRoomRequest(val title: String)
@Serializable data class FindRoomByTitleRequest(val title: String)
@Serializable data class UpdateMemberRoleRequest(val role: String)
@Serializable data class AuthResponse(val token: String, val user: UserResponse)
@Serializable data class UserResponse(val id: String, val username: String, val displayName: String, val isAdmin: Boolean) {
constructor(user: User) : this(user.id.toString(), user.username, user.displayName, user.isAdmin)
}
@Serializable data class InvitationResponse(val code: String, val type: String, val expiresAt: String)
@Serializable data class RoomResponse(val id: String, val title: String, val status: String, val role: String?) {
constructor(room: RoomRecord, role: RoomRole?) : this(room.id.toString(), room.title, room.status, role?.name)
}
@Serializable data class RoomMemberResponse(val roomId: String, val role: String) {
constructor(member: RoomMember) : this(member.roomId.toString(), member.role.name)
}
@Serializable data class TokenResponse(
val roomName: String,
val participantIdentity: String,
val participantName: String,
val livekitUrl: String,
val token: String,
val role: String,
)
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(apiKey, identity, participantName, now, now + 600, VideoGrant(true, roomName, canPublish, true, canPublish)),
)
val header = base64Url("""{"alg":"HS256","typ":"JWT"}""")
val unsignedToken = header + "." + base64Url(payload)
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))
}
@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)