272 lines
13 KiB
Kotlin
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)
|