tag(功能):完善登录功能

This commit is contained in:
OrangeRoll
2026-08-07 14:07:42 +08:00
parent 7f76df0002
commit 9c0a4f59f6
15 changed files with 966 additions and 244 deletions
+8
View File
@@ -5,3 +5,11 @@ POSTGRES_PASSWORD=change-me
LIVEKIT_API_KEY=devkey LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=replace-with-a-long-random-secret LIVEKIT_API_SECRET=replace-with-a-long-random-secret
LIVEKIT_PUBLIC_URL=ws://localhost:7880 LIVEKIT_PUBLIC_URL=ws://localhost:7880
AUTH_JWT_ISSUER=screen-share-api
AUTH_JWT_AUDIENCE=screen-share-web
AUTH_JWT_REALM=screen-share
AUTH_JWT_SECRET=replace-with-a-different-long-random-secret
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=change-this-admin-password
BOOTSTRAP_ADMIN_DISPLAY_NAME=管理员
+32
View File
@@ -0,0 +1,32 @@
# Screen Share
浏览器直接连接 LiveKit;Ktor 负责应用登录、邀请码、房间权限和短期 LiveKit Token。
## 本地启动
1. 启动基础服务:
~~~bash
sudo docker compose --env-file .env up -d postgres livekit
~~~
2. 用 IntelliJ 启动 API,并在 Program arguments 中填入:
~~~text
-config=src/main/resources/application.local.yaml
~~~
首次启动会执行 Flyway 迁移,并创建本地 bootstrap 管理员。
3. 启动前端:
~~~bash
cd apps/ScreenShareWeb
npm run dev
~~~
访问 http://localhost:5173,使用本地配置中的 bootstrap 管理员登录。管理员可生成普通用户或管理员邀请码;邀请码仅显示一次。
## 安全说明
.env 与 application.local.yaml 已被 Git 忽略。生产环境必须使用随机的数据库密码、JWT Secret、LiveKit Secret 和 bootstrap 管理员密码。
+6
View File
@@ -20,8 +20,14 @@ dependencies {
implementation(ktorLibs.server.netty) implementation(ktorLibs.server.netty)
implementation("io.ktor:ktor-server-content-negotiation-jvm:3.5.0") implementation("io.ktor:ktor-server-content-negotiation-jvm:3.5.0")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:3.5.0") implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:3.5.0")
implementation("io.ktor:ktor-server-auth-jwt-jvm:3.5.0")
implementation("io.ktor:ktor-server-cors-jvm:3.5.0") implementation("io.ktor:ktor-server-cors-jvm:3.5.0")
implementation("io.ktor:ktor-server-status-pages-jvm:3.5.0")
implementation("org.flywaydb:flyway-core:11.3.2")
implementation("org.flywaydb:flyway-database-postgresql:11.3.2")
implementation(libs.logback.classic) implementation(libs.logback.classic)
implementation("org.mindrot:jbcrypt:0.4")
runtimeOnly("org.postgresql:postgresql:42.7.12")
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
testImplementation(ktorLibs.server.testHost) testImplementation(ktorLibs.server.testHost)
@@ -1,11 +1,13 @@
package top.OrangeRoll package top.OrangeRoll
import configureRouting
import io.ktor.server.application.Application import io.ktor.server.application.Application
/** /**
* Ktor's YAML module entry point. * Ktor's YAML module entry point.
*/ */
fun Application.module() { fun Application.module() {
configureDatabase()
bootstrapAdministrator()
configureSecurity()
configureRouting() configureRouting()
} }
@@ -0,0 +1,22 @@
package top.OrangeRoll
import io.ktor.server.application.Application
/**
* Creates the first administrator only when it is absent.
* Keep these values in ignored application.local.yaml or environment variables.
*/
fun Application.bootstrapAdministrator() {
val config = environment.config
val username = config.propertyOrNull("bootstrapAdmin.username")?.getString()
?: System.getenv("BOOTSTRAP_ADMIN_USERNAME")
?: return
val password = config.propertyOrNull("bootstrapAdmin.password")?.getString()
?: System.getenv("BOOTSTRAP_ADMIN_PASSWORD")
?: return
val displayName = config.propertyOrNull("bootstrapAdmin.displayName")?.getString()
?: System.getenv("BOOTSTRAP_ADMIN_DISPLAY_NAME")
?: username
UserRepository.bootstrapAdmin(username, password, displayName)
}
@@ -0,0 +1,51 @@
package top.OrangeRoll
import io.ktor.server.application.Application
import io.ktor.server.application.log
import org.flywaydb.core.Flyway
import java.sql.Connection
import java.sql.DriverManager
/**
* Applies versioned SQL migrations before the API starts accepting requests.
*
* Configuration comes from application.local.yaml for local development or
* DATABASE_* environment variables when the API runs in Docker Compose.
*/
fun Application.configureDatabase() {
val config = environment.config
val jdbcUrl = config.propertyOrNull("database.jdbcUrl")?.getString()
?: System.getenv("DATABASE_JDBC_URL")
val user = config.propertyOrNull("database.user")?.getString()
?: System.getenv("DATABASE_USER")
val password = config.propertyOrNull("database.password")?.getString()
?: System.getenv("DATABASE_PASSWORD")
require(!jdbcUrl.isNullOrBlank()) { "Database JDBC URL is not configured" }
require(!user.isNullOrBlank()) { "Database user is not configured" }
require(password != null) { "Database password is not configured" }
val result = Flyway.configure()
.dataSource(jdbcUrl, user, password)
.locations("classpath:db/migration")
.load()
.migrate()
log.info("Database migration complete: ${result.migrationsExecuted} migration(s) applied")
Database.configure(jdbcUrl, user, password)
}
object Database {
private lateinit var jdbcUrl: String
private lateinit var user: String
private lateinit var password: String
fun configure(jdbcUrl: String, user: String, password: String) {
this.jdbcUrl = jdbcUrl
this.user = user
this.password = password
}
fun <T> useConnection(block: (Connection) -> T): T =
DriverManager.getConnection(jdbcUrl, user, password).use(block)
}
@@ -0,0 +1,269 @@
package top.OrangeRoll
import java.security.MessageDigest
import java.security.SecureRandom
import java.sql.Connection
import java.time.Instant
import java.util.Base64
import java.util.UUID
import org.mindrot.jbcrypt.BCrypt
data class User(
val id: UUID,
val username: String,
val passwordHash: String,
val displayName: String,
val isAdmin: Boolean,
)
data class RoomRecord(
val id: UUID,
val title: String,
val status: String,
val createdBy: UUID,
)
data class RoomMember(val roomId: UUID, val userId: UUID, val role: RoomRole)
enum class InvitationType { user, admin }
object UserRepository {
fun findByUsername(username: String): User? = Database.useConnection { connection ->
connection.prepareStatement(
"SELECT id, username, password_hash, display_name, is_admin FROM users WHERE username = ?",
).use { statement ->
statement.setString(1, username)
statement.executeQuery().use { result ->
if (result.next()) result.toUser() else null
}
}
}
fun findById(id: UUID): User? = Database.useConnection { connection ->
connection.prepareStatement(
"SELECT id, username, password_hash, display_name, is_admin FROM users WHERE id = ?",
).use { statement ->
statement.setObject(1, id)
statement.executeQuery().use { result ->
if (result.next()) result.toUser() else null
}
}
}
fun bootstrapAdmin(username: String, password: String, displayName: String) {
if (findByUsername(username) != null) return
Database.useConnection { connection ->
connection.prepareStatement(
"INSERT INTO users (id, username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, TRUE)",
).use { statement ->
statement.setObject(1, UUID.randomUUID())
statement.setString(2, username)
statement.setString(3, BCrypt.hashpw(password, BCrypt.gensalt()))
statement.setString(4, displayName)
statement.executeUpdate()
}
}
}
fun register(inviteCode: String, username: String, password: String, displayName: String): User =
Database.useConnection { connection ->
connection.autoCommit = false
try {
val invitation = connection.prepareStatement(
"""
SELECT id, invitation_type, expires_at, used_at
FROM invitations
WHERE code_hash = ?
FOR UPDATE
""".trimIndent(),
).use { statement ->
statement.setString(1, hashInviteCode(inviteCode))
statement.executeQuery().use { result ->
if (!result.next()) throw ApiException("邀请码无效", 400)
InviteRow(
id = result.getObject("id", UUID::class.java),
type = InvitationType.valueOf(result.getString("invitation_type")),
expiresAt = result.getTimestamp("expires_at").toInstant(),
usedAt = result.getTimestamp("used_at")?.toInstant(),
)
}
}
if (invitation.usedAt != null || invitation.expiresAt.isBefore(Instant.now())) {
throw ApiException("邀请码已使用或已过期", 400)
}
val user = User(
id = UUID.randomUUID(),
username = username,
passwordHash = BCrypt.hashpw(password, BCrypt.gensalt()),
displayName = displayName,
isAdmin = invitation.type == InvitationType.admin,
)
connection.prepareStatement(
"INSERT INTO users (id, username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, ?)",
).use { statement ->
statement.setObject(1, user.id)
statement.setString(2, user.username)
statement.setString(3, user.passwordHash)
statement.setString(4, user.displayName)
statement.setBoolean(5, user.isAdmin)
statement.executeUpdate()
}
connection.prepareStatement(
"UPDATE invitations SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?",
).use { statement ->
statement.setObject(1, user.id)
statement.setObject(2, invitation.id)
statement.executeUpdate()
}
connection.commit()
user
} catch (error: Throwable) {
connection.rollback()
throw error
} finally {
connection.autoCommit = true
}
}
private data class InviteRow(
val id: UUID,
val type: InvitationType,
val expiresAt: Instant,
val usedAt: Instant?,
)
}
object InvitationRepository {
fun create(createdBy: UUID, type: InvitationType, expiresAt: Instant): String {
val code = Base64.getUrlEncoder().withoutPadding()
.encodeToString(SecureRandom().generateSeed(24))
Database.useConnection { connection ->
connection.prepareStatement(
"""
INSERT INTO invitations (id, code_hash, invitation_type, created_by, expires_at)
VALUES (?, ?, ?, ?, ?)
""".trimIndent(),
).use { statement ->
statement.setObject(1, UUID.randomUUID())
statement.setString(2, hashInviteCode(code))
statement.setString(3, type.name)
statement.setObject(4, createdBy)
statement.setTimestamp(5, java.sql.Timestamp.from(expiresAt))
statement.executeUpdate()
}
}
return code
}
}
object RoomRepository {
fun create(hostId: UUID, title: String): RoomRecord {
val room = RoomRecord(UUID.randomUUID(), title, "open", hostId)
Database.useConnection { connection ->
connection.autoCommit = false
try {
connection.prepareStatement(
"INSERT INTO rooms (id, slug, title, created_by) VALUES (?, ?, ?, ?)",
).use { statement ->
statement.setObject(1, room.id)
statement.setString(2, room.id.toString())
statement.setString(3, room.title)
statement.setObject(4, hostId)
statement.executeUpdate()
}
connection.prepareStatement(
"INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, 'host')",
).use { statement ->
statement.setObject(1, room.id)
statement.setObject(2, hostId)
statement.executeUpdate()
}
connection.commit()
} catch (error: Throwable) {
connection.rollback()
throw error
} finally {
connection.autoCommit = true
}
}
return room
}
fun find(roomId: UUID): RoomRecord? = Database.useConnection { connection ->
connection.prepareStatement("SELECT id, title, status, created_by FROM rooms WHERE id = ?").use { statement ->
statement.setObject(1, roomId)
statement.executeQuery().use { result ->
if (result.next()) {
RoomRecord(
result.getObject("id", UUID::class.java),
result.getString("title"),
result.getString("status"),
result.getObject("created_by", UUID::class.java),
)
} else null
}
}
}
fun member(roomId: UUID, userId: UUID): RoomMember? = Database.useConnection { connection ->
connection.prepareStatement(
"SELECT room_id, user_id, role FROM room_members WHERE room_id = ? AND user_id = ?",
).use { statement ->
statement.setObject(1, roomId)
statement.setObject(2, userId)
statement.executeQuery().use { result ->
if (result.next()) {
RoomMember(
result.getObject("room_id", UUID::class.java),
result.getObject("user_id", UUID::class.java),
RoomRole.valueOf(result.getString("role")),
)
} else null
}
}
}
fun joinAsViewer(roomId: UUID, userId: UUID): RoomMember {
val room = find(roomId) ?: throw ApiException("房间不存在", 404)
if (room.status != "open") throw ApiException("房间已关闭", 403)
Database.useConnection { connection ->
connection.prepareStatement(
"INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, 'viewer') ON CONFLICT DO NOTHING",
).use { statement ->
statement.setObject(1, roomId)
statement.setObject(2, userId)
statement.executeUpdate()
}
}
return member(roomId, userId) ?: error("Room membership was not created")
}
fun updateRole(roomId: UUID, userId: UUID, role: RoomRole): RoomMember {
Database.useConnection { connection ->
connection.prepareStatement(
"UPDATE room_members SET role = ? WHERE room_id = ? AND user_id = ?",
).use { statement ->
statement.setString(1, role.name)
statement.setObject(2, roomId)
statement.setObject(3, userId)
if (statement.executeUpdate() == 0) throw ApiException("房间成员不存在", 404)
}
}
return member(roomId, userId) ?: error("Room membership was not updated")
}
}
fun hashInviteCode(value: String): String =
MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString("") { byte -> "%02x".format(byte) }
private fun java.sql.ResultSet.toUser(): User =
User(
id = getObject("id", UUID::class.java),
username = getString("username"),
passwordHash = getString("password_hash"),
displayName = getString("display_name"),
isAdmin = getBoolean("is_admin"),
)
+182 -128
View File
@@ -1,12 +1,20 @@
package top.OrangeRoll
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.server.application.* 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.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.cors.routing.CORS 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.request.receive
import io.ktor.server.response.* import io.ktor.server.response.respond
import io.ktor.server.routing.* 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 io.ktor.serialization.kotlinx.json.json
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.time.Instant import java.time.Instant
@@ -16,10 +24,14 @@ import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.mindrot.jbcrypt.BCrypt
fun Application.configureRouting() { fun Application.configureRouting() {
install(ContentNegotiation) { install(ContentNegotiation) { json() }
json() install(StatusPages) {
exception<ApiException> { call, error ->
call.respond(HttpStatusCode.fromValue(error.status), ErrorResponse(error.message))
}
} }
install(CORS) { install(CORS) {
val configuredOrigins = this@configureRouting.environment.config val configuredOrigins = this@configureRouting.environment.config
@@ -29,95 +41,192 @@ fun Application.configureRouting() {
?.map(String::trim) ?.map(String::trim)
?.filter(String::isNotEmpty) ?.filter(String::isNotEmpty)
.orEmpty() .orEmpty()
configuredOrigins.ifEmpty { listOf("http://localhost:5173") }.forEach { origin -> configuredOrigins.ifEmpty { listOf("http://localhost:5173") }.forEach { origin ->
val host = origin.removePrefix("http://").removePrefix("https://")
allowHost( allowHost(
host = host, host = origin.removePrefix("http://").removePrefix("https://"),
schemes = listOf(if (origin.startsWith("https://")) "https" else "http"), schemes = listOf(if (origin.startsWith("https://")) "https" else "http"),
) )
} }
allowMethod(HttpMethod.Post) allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Get) allowMethod(HttpMethod.Get)
allowHeader(HttpHeaders.ContentType) allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Authorization)
} }
routing { routing {
get("/health") { get("/health") { call.respond(HealthResponse("ok")) }
call.respond(HealthResponse(status = "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)
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/{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") { post("/api/rooms/{roomId}/token") {
val roomName = call.parameters["roomId"]?.trim().orEmpty() val user = call.requireCurrentUser()
if (!ROOM_NAME.matches(roomName)) { val room = RoomRepository.find(call.roomId()) ?: throw ApiException("房间不存在", 404)
call.respond( val membership = RoomRepository.member(room.id, user.id)
HttpStatusCode.BadRequest, ?: throw ApiException("请先加入房间", 403)
ErrorResponse("roomId must contain 3-64 letters, numbers, underscores, or hyphens"), val liveKit = liveKitSettings()
val token = LiveKitTokenIssuer(liveKit.apiKey, liveKit.apiSecret).issue(
roomName = room.id.toString(),
identity = user.id.toString(),
participantName = user.displayName,
role = membership.role,
) )
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( call.respond(
TokenResponse( TokenResponse(
roomName = roomName, roomName = room.id.toString(),
participantIdentity = identity, participantIdentity = user.id.toString(),
participantName = participantName, participantName = user.displayName,
livekitUrl = publicUrl, livekitUrl = liveKit.publicUrl,
token = token, token = token,
role = role.name, role = membership.role.name,
), ),
) )
} }
} }
} }
}
private val ROOM_NAME = Regex("[A-Za-z0-9_-]{3,64}") private fun Application.authResponse(user: User) =
AuthResponse(issueApplicationToken(user), UserResponse(user))
@Serializable private fun io.ktor.server.application.ApplicationCall.requireCurrentUser(): User =
data class HealthResponse(val status: String) UserRepository.findById(currentUserId()) ?: throw ApiException("用户不存在", 401)
@Serializable private fun io.ktor.server.application.ApplicationCall.requireAdministrator(): User =
data class TokenRequest( requireCurrentUser().takeIf { it.isAdmin } ?: throw ApiException("需要管理员权限", 403)
val participantName: String,
val role: String = "viewer", 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"),
) )
}
@Serializable private data class LiveKitSettings(val apiKey: String, val apiSecret: String, val publicUrl: String)
data class TokenResponse(
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 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 roomName: String,
val participantIdentity: String, val participantIdentity: String,
val participantName: String, val participantName: String,
@@ -126,80 +235,25 @@ data class TokenResponse(
val role: String, val role: String,
) )
@Serializable private class LiveKitTokenIssuer(private val apiKey: String, private val apiSecret: String) {
data class ErrorResponse(val message: String) fun issue(roomName: String, identity: String, participantName: String, role: RoomRole): 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 now = Instant.now().epochSecond
val canPublish = role == RoomRole.host || role == RoomRole.publisher val canPublish = role == RoomRole.host || role == RoomRole.publisher
val payload = Json.encodeToString( val payload = Json.encodeToString(
LiveKitClaims.serializer(), LiveKitClaims.serializer(),
LiveKitClaims( LiveKitClaims(apiKey, identity, participantName, now, now + 600, VideoGrant(true, roomName, canPublish, true, canPublish)),
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 header = base64Url("""{"alg":"HS256","typ":"JWT"}""")
val body = base64Url(payload) val unsignedToken = header + "." + base64Url(payload)
val unsignedToken = "$header.$body"
val signature = Mac.getInstance("HmacSHA256") val signature = Mac.getInstance("HmacSHA256")
.apply { init(SecretKeySpec(apiSecret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")) } .apply { init(SecretKeySpec(apiSecret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")) }
.doFinal(unsignedToken.toByteArray(StandardCharsets.UTF_8)) .doFinal(unsignedToken.toByteArray(StandardCharsets.UTF_8))
return "$unsignedToken.${Base64.getUrlEncoder().withoutPadding().encodeToString(signature)}" return unsignedToken + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(signature)
} }
private fun base64Url(value: String): String = private fun base64Url(value: String): String =
Base64.getUrlEncoder().withoutPadding().encodeToString(value.toByteArray(StandardCharsets.UTF_8)) Base64.getUrlEncoder().withoutPadding().encodeToString(value.toByteArray(StandardCharsets.UTF_8))
private companion object {
const val TOKEN_TTL_SECONDS = 10 * 60L
}
} }
@Serializable @Serializable private data class LiveKitClaims(val iss: String, val sub: String, val name: String, val nbf: Long, val exp: Long, val video: VideoGrant)
private data class LiveKitClaims( @Serializable private data class VideoGrant(val roomJoin: Boolean, val room: String, val canPublish: Boolean, val canSubscribe: Boolean, val canPublishData: Boolean)
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,
)
@@ -0,0 +1,73 @@
package top.OrangeRoll
import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.jwt.jwt
import io.ktor.server.auth.principal
import java.time.Instant
import java.util.Date
data class AuthSettings(
val issuer: String,
val audience: String,
val realm: String,
val secret: String,
)
fun Application.authSettings(): AuthSettings {
val config = environment.config
fun value(path: String, environmentVariable: String): String =
config.propertyOrNull(path)?.getString()
?: System.getenv(environmentVariable)
?: error("$path is not configured")
return AuthSettings(
issuer = value("auth.issuer", "AUTH_JWT_ISSUER"),
audience = value("auth.audience", "AUTH_JWT_AUDIENCE"),
realm = value("auth.realm", "AUTH_JWT_REALM"),
secret = value("auth.jwtSecret", "AUTH_JWT_SECRET"),
)
}
fun Application.configureSecurity() {
val settings = authSettings()
install(Authentication) {
jwt("auth-jwt") {
realm = settings.realm
verifier(jwtVerifier(settings))
validate { credential ->
credential.payload.subject
?.let { JWTPrincipal(credential.payload) }
}
}
}
}
fun Application.issueApplicationToken(user: User): String {
val settings = authSettings()
return JWT.create()
.withIssuer(settings.issuer)
.withAudience(settings.audience)
.withSubject(user.id.toString())
.withClaim("username", user.username)
.withExpiresAt(Date.from(Instant.now().plusSeconds(8 * 60 * 60)))
.sign(Algorithm.HMAC256(settings.secret))
}
fun ApplicationCall.currentUserId(): java.util.UUID =
java.util.UUID.fromString(
principal<JWTPrincipal>()?.payload?.subject
?: error("Authenticated user is missing from the token"),
)
private fun jwtVerifier(settings: AuthSettings): JWTVerifier =
JWT.require(Algorithm.HMAC256(settings.secret))
.withIssuer(settings.issuer)
.withAudience(settings.audience)
.build()
@@ -10,3 +10,19 @@ livekit:
apiKey: devkey apiKey: devkey
apiSecret: replace-with-a-long-random-secret apiSecret: replace-with-a-long-random-secret
publicUrl: ws://localhost:7880 publicUrl: ws://localhost:7880
database:
jdbcUrl: jdbc:postgresql://localhost:5432/screenshare
user: screenshare
password: change-me
auth:
issuer: screen-share-api
audience: screen-share-web
realm: screen-share
jwtSecret: replace-with-a-different-long-random-secret
bootstrapAdmin:
username: admin
password: change-this-admin-password
displayName: 管理员
@@ -0,0 +1,41 @@
CREATE TABLE users (
id UUID PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE rooms (
id UUID PRIMARY KEY,
slug VARCHAR(64) NOT NULL UNIQUE,
title VARCHAR(128) NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
status VARCHAR(16) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'closed')),
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMPTZ
);
CREATE TABLE room_members (
room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(16) NOT NULL
CHECK (role IN ('host', 'publisher', 'viewer')),
joined_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (room_id, user_id)
);
CREATE TABLE audit_logs (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id UUID REFERENCES rooms(id) ON DELETE SET NULL,
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
event_type VARCHAR(64) NOT NULL,
event_data JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_rooms_created_by ON rooms(created_by);
CREATE INDEX idx_room_members_user_id ON room_members(user_id);
CREATE INDEX idx_audit_logs_room_id_created_at ON audit_logs(room_id, created_at DESC);
@@ -0,0 +1,17 @@
ALTER TABLE users
ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE invitations (
id UUID PRIMARY KEY,
code_hash CHAR(64) NOT NULL UNIQUE,
invitation_type VARCHAR(16) NOT NULL
CHECK (invitation_type IN ('user', 'admin')),
created_by UUID NOT NULL REFERENCES users(id),
expires_at TIMESTAMPTZ NOT NULL,
used_by UUID REFERENCES users(id),
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK ((used_by IS NULL AND used_at IS NULL) OR (used_by IS NOT NULL AND used_at IS NOT NULL))
);
CREATE INDEX idx_invitations_created_by ON invitations(created_by);
+51
View File
@@ -4,6 +4,53 @@
padding: 56px 0; padding: 56px 0;
} }
.app-shell.narrow {
width: min(460px, calc(100% - 32px));
}
.app-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.auth-card, .admin-panel {
display: grid;
gap: 14px;
padding: 22px;
border: 1px solid #e0e1ee;
border-radius: 16px;
background: #fff;
box-shadow: 0 8px 30px rgb(30 33 80 / 7%);
}
.admin-panel {
grid-template-columns: auto auto auto 1fr;
align-items: center;
margin-bottom: 20px;
}
.admin-panel button {
min-height: 36px;
background: #ecebff;
color: #4536c7;
}
.invite-code {
overflow-wrap: anywhere;
color: #4536c7;
}
.link-button {
min-height: auto;
padding: 0;
color: #5547d4;
background: transparent;
font-weight: 500;
text-align: left;
}
header { header {
margin-bottom: 32px; margin-bottom: 32px;
} }
@@ -195,6 +242,10 @@ button:disabled {
flex-wrap: wrap; flex-wrap: wrap;
} }
.app-header, .admin-panel {
display: grid;
}
.share-button { .share-button {
margin-left: 0; margin-left: 0;
} }
+185 -118
View File
@@ -4,6 +4,15 @@ import './App.css'
type Role = 'host' | 'publisher' | 'viewer' type Role = 'host' | 'publisher' | 'viewer'
type User = {
id: string
username: string
displayName: string
isAdmin: boolean
}
type Session = { token: string; user: User }
type TokenResponse = { type TokenResponse = {
roomName: string roomName: string
participantIdentity: string participantIdentity: string
@@ -13,10 +22,7 @@ type TokenResponse = {
role: Role role: Role
} }
type ScreenFeed = { type ScreenFeed = { track: Track; label: string }
track: Track
label: string
}
function ScreenTrack({ track, label }: ScreenFeed) { function ScreenTrack({ track, label }: ScreenFeed) {
const mediaContainerRef = useRef<HTMLDivElement>(null) const mediaContainerRef = useRef<HTMLDivElement>(null)
@@ -25,18 +31,12 @@ function ScreenTrack({ track, label }: ScreenFeed) {
const mediaElement = track.attach() const mediaElement = track.attach()
mediaElement.autoplay = true mediaElement.autoplay = true
if (mediaElement instanceof HTMLVideoElement) { if (mediaElement instanceof HTMLVideoElement) {
// Local preview must be muted for browsers to allow autoplay.
mediaElement.muted = true mediaElement.muted = true
mediaElement.playsInline = true mediaElement.playsInline = true
void mediaElement.play().catch(() => { void mediaElement.play().catch(() => undefined)
// The user can still start playback manually if their browser blocks it.
})
} }
mediaContainerRef.current?.append(mediaElement) mediaContainerRef.current?.append(mediaElement)
return () => mediaElement.remove()
return () => {
mediaElement.remove()
}
}, [track]) }, [track])
return ( return (
@@ -47,60 +47,138 @@ function ScreenTrack({ track, label }: ScreenFeed) {
) )
} }
async function apiRequest<T>(path: string, options: RequestInit = {}, token?: string): Promise<T> {
const response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
...options.headers,
},
})
const payload = (await response.json()) as T | { message: string }
if (!response.ok) {
const message = typeof payload === 'object' && payload !== null && 'message' in payload
? String(payload.message)
: '请求失败'
throw new Error(message)
}
return payload as T
}
function App() { function App() {
const roomRef = useRef<Room | null>(null) const roomRef = useRef<Room | null>(null)
const [roomName, setRoomName] = useState('demo-room') const [session, setSession] = useState<Session | null>(null)
const [participantName, setParticipantName] = useState('') const [authMode, setAuthMode] = useState<'login' | 'register'>('login')
const [role, setRole] = useState<Role>('publisher') const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [inviteCode, setInviteCode] = useState('')
const [roomId, setRoomId] = useState('')
const [roomTitle, setRoomTitle] = useState('')
const [connectionState, setConnectionState] = useState<'idle' | 'connecting' | 'connected'>('idle') const [connectionState, setConnectionState] = useState<'idle' | 'connecting' | 'connected'>('idle')
const [roomRole, setRoomRole] = useState<Role | null>(null)
const [isSharing, setIsSharing] = useState(false) const [isSharing, setIsSharing] = useState(false)
const [remoteCount, setRemoteCount] = useState(0) const [remoteCount, setRemoteCount] = useState(0)
const [localScreen, setLocalScreen] = useState<ScreenFeed | null>(null) const [localScreen, setLocalScreen] = useState<ScreenFeed | null>(null)
const [remoteScreens, setRemoteScreens] = useState<ScreenFeed[]>([]) const [remoteScreens, setRemoteScreens] = useState<ScreenFeed[]>([])
const [createdInvite, setCreatedInvite] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
useEffect(() => { useEffect(() => {
return () => { const stored = localStorage.getItem('screen-share-session')
roomRef.current?.disconnect() if (!stored) return
} const saved = JSON.parse(stored) as Session
void apiRequest<User>('/api/auth/me', {}, saved.token)
.then((user) => setSession({ token: saved.token, user }))
.catch(() => localStorage.removeItem('screen-share-session'))
}, []) }, [])
useEffect(() => () => {
void roomRef.current?.disconnect()
}, [])
function saveSession(next: Session) {
localStorage.setItem('screen-share-session', JSON.stringify(next))
setSession(next)
}
function refreshRemoteCount(room: Room) { function refreshRemoteCount(room: Room) {
setRemoteCount(room.remoteParticipants.size) setRemoteCount(room.remoteParticipants.size)
} }
async function joinRoom() { async function submitAuth() {
const normalizedRoom = roomName.trim() setError('')
const normalizedName = participantName.trim() try {
const body = authMode === 'login'
if (!/^[A-Za-z0-9_-]{3,64}$/.test(normalizedRoom)) { ? { username, password }
setError('房间号只能包含 3–64 个字母、数字、下划线或连字符。') : { username, password, displayName, inviteCode }
return const result = await apiRequest<Session>('/api/auth/' + authMode, {
method: 'POST',
body: JSON.stringify(body),
})
saveSession(result)
setPassword('')
setInviteCode('')
} catch (cause) {
setError(cause instanceof Error ? cause.message : '认证失败')
} }
if (!normalizedName) {
setError('请输入显示名称。')
return
} }
async function createInvite(type: 'user' | 'admin') {
if (!session) return
setError('')
try {
const result = await apiRequest<{ code: string }>('/api/admin/invitations', {
method: 'POST',
body: JSON.stringify({ type, expiresInHours: 168 }),
}, session.token)
setCreatedInvite(result.code)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '创建邀请码失败')
}
}
async function createRoom() {
if (!session || !roomTitle.trim()) {
setError('请输入房间标题。')
return
}
setError('')
try {
const room = await apiRequest<{ id: string }>('/api/rooms', {
method: 'POST',
body: JSON.stringify({ title: roomTitle.trim() }),
}, session.token)
setRoomId(room.id)
await connectRoom(room.id)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '创建房间失败')
}
}
async function connectRoom(targetRoomId = roomId.trim()) {
if (!session) return
if (!targetRoomId) {
setError('请输入房间 ID。')
return
}
setError('') setError('')
setConnectionState('connecting') setConnectionState('connecting')
try { try {
const response = await fetch(`/api/rooms/${encodeURIComponent(normalizedRoom)}/token`, { await apiRequest('/api/rooms/' + encodeURIComponent(targetRoomId) + '/join', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: '{}',
body: JSON.stringify({ participantName: normalizedName, role }), }, session.token)
}) const payload = await apiRequest<TokenResponse>('/api/rooms/' + encodeURIComponent(targetRoomId) + '/token', {
const payload = (await response.json()) as TokenResponse | { message: string } method: 'POST',
body: '{}',
if (!response.ok || !('token' in payload)) { }, session.token)
throw new Error('message' in payload ? payload.message : '无法获取房间凭证。')
}
roomRef.current?.disconnect() roomRef.current?.disconnect()
setLocalScreen(null) setLocalScreen(null)
setRemoteScreens([]) setRemoteScreens([])
const room = new Room() const room = new Room()
room.on(RoomEvent.ParticipantConnected, () => refreshRemoteCount(room)) room.on(RoomEvent.ParticipantConnected, () => refreshRemoteCount(room))
room.on(RoomEvent.ParticipantDisconnected, () => refreshRemoteCount(room)) room.on(RoomEvent.ParticipantDisconnected, () => refreshRemoteCount(room))
@@ -108,7 +186,7 @@ function App() {
if (track.kind === Track.Kind.Video && publication.source === Track.Source.ScreenShare) { if (track.kind === Track.Kind.Video && publication.source === Track.Source.ScreenShare) {
setRemoteScreens((screens) => [ setRemoteScreens((screens) => [
...screens.filter((screen) => screen.track.sid !== track.sid), ...screens.filter((screen) => screen.track.sid !== track.sid),
{ track, label: `${participant.name || participant.identity} 的屏幕` }, { track, label: (participant.name || participant.identity) + ' 的屏幕' },
]) ])
} }
}) })
@@ -129,6 +207,7 @@ function App() {
}) })
room.on(RoomEvent.Disconnected, () => { room.on(RoomEvent.Disconnected, () => {
setConnectionState('idle') setConnectionState('idle')
setRoomRole(null)
setIsSharing(false) setIsSharing(false)
setRemoteCount(0) setRemoteCount(0)
setLocalScreen(null) setLocalScreen(null)
@@ -137,122 +216,110 @@ function App() {
await room.connect(payload.livekitUrl, payload.token) await room.connect(payload.livekitUrl, payload.token)
roomRef.current = room roomRef.current = room
setRoomId(targetRoomId)
setRoomRole(payload.role)
refreshRemoteCount(room) refreshRemoteCount(room)
setConnectionState('connected') setConnectionState('connected')
} catch (cause) { } catch (cause) {
setConnectionState('idle') setConnectionState('idle')
setError(cause instanceof Error ? cause.message : '连接房间时发生未知错误。') setError(cause instanceof Error ? cause.message : '连接房间失败')
} }
} }
async function toggleScreenShare() { async function toggleScreenShare() {
const room = roomRef.current if (!roomRef.current) return
if (!room) {
return
}
setError('')
try { try {
await room.localParticipant.setScreenShareEnabled(!isSharing) await roomRef.current.localParticipant.setScreenShareEnabled(!isSharing)
} catch (cause) { } catch (cause) {
setError(cause instanceof Error ? cause.message : '无法切换屏幕共享') setError(cause instanceof Error ? cause.message : '无法切换屏幕共享')
} }
} }
function leaveRoom() { function leaveRoom() {
roomRef.current?.disconnect() roomRef.current?.disconnect()
roomRef.current = null roomRef.current = null
setConnectionState('idle') }
setIsSharing(false)
setRemoteCount(0) function logout() {
setLocalScreen(null) leaveRoom()
setRemoteScreens([]) localStorage.removeItem('screen-share-session')
setSession(null)
setCreatedInvite('')
} }
const connected = connectionState === 'connected' const connected = connectionState === 'connected'
const canShare = role === 'host' || role === 'publisher' const canShare = roomRole === 'host' || roomRole === 'publisher'
if (!session) {
return (
<main className="app-shell narrow">
<header>
<p className="eyebrow">SCREEN SHARE</p>
<h1>{authMode === 'login' ? '登录' : '使用邀请码注册'}</h1>
</header>
<section className="auth-card">
<label><input value={username} onChange={(event) => setUsername(event.target.value)} /></label>
<label><input type="password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
{authMode === 'register' && (
<>
<label><input value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>
<label><input value={inviteCode} onChange={(event) => setInviteCode(event.target.value)} /></label>
</>
)}
<button type="button" className="primary" onClick={() => void submitAuth()}>
{authMode === 'login' ? '登录' : '注册并登录'}
</button>
<button type="button" className="link-button" onClick={() => setAuthMode(authMode === 'login' ? 'register' : 'login')}>
{authMode === 'login' ? '没有账号?使用邀请码注册' : '已有账号?去登录'}
</button>
{error && <p className="error" role="alert">{error}</p>}
</section>
</main>
)
}
return ( return (
<main className="app-shell"> <main className="app-shell">
<header> <header className="app-header">
<p className="eyebrow">LIVEKIT · </p> <div>
<p className="eyebrow">LIVEKIT · </p>
<h1></h1> <h1></h1>
<p className="subtitle"> LiveKitKtor 访</p> <p className="subtitle">{session.user.displayName}{session.user.isAdmin ? '管理员' : '普通用户'}</p>
</div>
<button type="button" className="secondary" onClick={logout}>退</button>
</header> </header>
<section className="join-panel" aria-label="加入房间"> {session.user.isAdmin && (
<label> <section className="admin-panel">
<strong></strong>
<input <button type="button" onClick={() => void createInvite('user')}></button>
value={roomName} <button type="button" onClick={() => void createInvite('admin')}></button>
onChange={(event) => setRoomName(event.target.value)} {createdInvite && <code className="invite-code">{createdInvite}</code>}
disabled={connected} </section>
maxLength={64}
/>
</label>
<label>
<input
value={participantName}
onChange={(event) => setParticipantName(event.target.value)}
disabled={connected}
maxLength={64}
placeholder="例如:张三"
/>
</label>
<label>
<select value={role} onChange={(event) => setRole(event.target.value as Role)} disabled={connected}>
<option value="host">Host</option>
<option value="publisher">Publisher</option>
<option value="viewer">Viewer</option>
</select>
</label>
{!connected ? (
<button type="button" className="primary" onClick={() => void joinRoom()} disabled={connectionState === 'connecting'}>
{connectionState === 'connecting' ? '正在连接…' : '加入房间'}
</button>
) : (
<button type="button" className="secondary" onClick={leaveRoom}></button>
)} )}
<section className="join-panel">
<label><input value={roomTitle} onChange={(event) => setRoomTitle(event.target.value)} disabled={connected} /></label>
<button type="button" className="primary" onClick={() => void createRoom()} disabled={connected}></button>
<label> ID<input value={roomId} onChange={(event) => setRoomId(event.target.value)} disabled={connected} placeholder="创建后或从他人处获得" /></label>
{!connected
? <button type="button" className="secondary" onClick={() => void connectRoom()} disabled={connectionState === 'connecting'}>{connectionState === 'connecting' ? '正在连接…' : '加入房间'}</button>
: <button type="button" className="secondary" onClick={leaveRoom}></button>}
</section> </section>
{error && <p className="error" role="alert">{error}</p>} {error && <p className="error" role="alert">{error}</p>}
<section className="status-bar" aria-live="polite"> <section className="status-bar" aria-live="polite">
<span className={connected ? 'status online' : 'status'}>{connected ? '已连接' : '未连接'}</span> <span className={connected ? 'status online' : 'status'}>{connected ? '已连接' : '未连接'}</span>
<span>{roomRole ? '房间角色:' + roomRole : '尚未加入房间'}</span>
<span>{remoteCount}</span> <span>{remoteCount}</span>
{connected && ( {connected && <button type="button" className="share-button" onClick={() => void toggleScreenShare()} disabled={!canShare}>{isSharing ? '停止共享屏幕' : '共享屏幕'}</button>}
<button type="button" className="share-button" onClick={() => void toggleScreenShare()} disabled={!canShare}>
{isSharing ? '停止共享屏幕' : '共享屏幕'}
</button>
)}
{connected && !canShare && <span className="hint">Viewer </span>}
</section> </section>
<section className="screens"> <section className="screens">
<div className="screen-section"> <div className="screen-section"><h2></h2><div className="video-stack">{localScreen ? <ScreenTrack {...localScreen} /> : <p className="empty"></p>}</div></div>
<h2></h2> <div className="screen-section"><h2></h2><div className="video-stack">{remoteScreens.length === 0 ? <p className="empty"></p> : remoteScreens.map((screen) => <ScreenTrack key={screen.track.sid} {...screen} />)}</div></div>
<div className="video-stack">
{localScreen ? <ScreenTrack {...localScreen} /> : <p className="empty"></p>}
</div>
</div>
<div className="screen-section">
<h2></h2>
<div className="video-stack">
{remoteScreens.length === 0
? <p className="empty"></p>
: remoteScreens.map((screen) => (
<ScreenTrack key={screen.track.sid} {...screen} />
))}
</div>
</div>
</section> </section>
<p className="development-note">
JWT
</p>
</main> </main>
) )
} }
+14 -1
View File
@@ -36,8 +36,21 @@ services:
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY} LIVEKIT_API_KEY: ${LIVEKIT_API_KEY}
LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET} LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET}
LIVEKIT_PUBLIC_URL: ${LIVEKIT_PUBLIC_URL:-ws://localhost:7880} LIVEKIT_PUBLIC_URL: ${LIVEKIT_PUBLIC_URL:-ws://localhost:7880}
DATABASE_JDBC_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
DATABASE_USER: ${POSTGRES_USER}
DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
AUTH_JWT_ISSUER: ${AUTH_JWT_ISSUER}
AUTH_JWT_AUDIENCE: ${AUTH_JWT_AUDIENCE}
AUTH_JWT_REALM: ${AUTH_JWT_REALM}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET}
BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME}
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD}
BOOTSTRAP_ADMIN_DISPLAY_NAME: ${BOOTSTRAP_ADMIN_DISPLAY_NAME}
depends_on: depends_on:
- livekit livekit:
condition: service_started
postgres:
condition: service_healthy
ports: ports:
- "8080:8080" - "8080:8080"