From 9c0a4f59f633bb5ed84602f9fe5723e560ba2984 Mon Sep 17 00:00:00 2001 From: OrangeRoll Date: Fri, 7 Aug 2026 14:07:42 +0800 Subject: [PATCH] =?UTF-8?q?tag(=E5=8A=9F=E8=83=BD):=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 + README.md | 32 ++ apps/ScreenShareApi/build.gradle.kts | 6 + .../src/main/kotlin/Application.kt | 4 +- .../src/main/kotlin/Bootstrap.kt | 22 ++ .../src/main/kotlin/Database.kt | 51 +++ .../src/main/kotlin/Repositories.kt | 269 ++++++++++++++++ .../ScreenShareApi/src/main/kotlin/Routing.kt | 304 +++++++++++------- .../src/main/kotlin/Security.kt | 73 +++++ .../resources/application.local.example.yaml | 16 + .../db/migration/V1__create_core_schema.sql | 41 +++ ...V2__add_administration_and_invitations.sql | 17 + apps/ScreenShareWeb/src/App.css | 51 +++ apps/ScreenShareWeb/src/App.tsx | 301 ++++++++++------- docker-compose.yml | 15 +- 15 files changed, 966 insertions(+), 244 deletions(-) create mode 100644 README.md create mode 100644 apps/ScreenShareApi/src/main/kotlin/Bootstrap.kt create mode 100644 apps/ScreenShareApi/src/main/kotlin/Database.kt create mode 100644 apps/ScreenShareApi/src/main/kotlin/Repositories.kt create mode 100644 apps/ScreenShareApi/src/main/kotlin/Security.kt create mode 100644 apps/ScreenShareApi/src/main/resources/db/migration/V1__create_core_schema.sql create mode 100644 apps/ScreenShareApi/src/main/resources/db/migration/V2__add_administration_and_invitations.sql diff --git a/.env.example b/.env.example index b877a10..73aea08 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,11 @@ POSTGRES_PASSWORD=change-me LIVEKIT_API_KEY=devkey LIVEKIT_API_SECRET=replace-with-a-long-random-secret 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=管理员 diff --git a/README.md b/README.md new file mode 100644 index 0000000..13939bb --- /dev/null +++ b/README.md @@ -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 管理员密码。 diff --git a/apps/ScreenShareApi/build.gradle.kts b/apps/ScreenShareApi/build.gradle.kts index 63eea29..f95a4c4 100644 --- a/apps/ScreenShareApi/build.gradle.kts +++ b/apps/ScreenShareApi/build.gradle.kts @@ -20,8 +20,14 @@ dependencies { implementation(ktorLibs.server.netty) 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-server-auth-jwt-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("org.mindrot:jbcrypt:0.4") + runtimeOnly("org.postgresql:postgresql:42.7.12") testImplementation(kotlin("test")) testImplementation(ktorLibs.server.testHost) diff --git a/apps/ScreenShareApi/src/main/kotlin/Application.kt b/apps/ScreenShareApi/src/main/kotlin/Application.kt index 376c6bc..849f0cd 100644 --- a/apps/ScreenShareApi/src/main/kotlin/Application.kt +++ b/apps/ScreenShareApi/src/main/kotlin/Application.kt @@ -1,11 +1,13 @@ package top.OrangeRoll -import configureRouting import io.ktor.server.application.Application /** * Ktor's YAML module entry point. */ fun Application.module() { + configureDatabase() + bootstrapAdministrator() + configureSecurity() configureRouting() } diff --git a/apps/ScreenShareApi/src/main/kotlin/Bootstrap.kt b/apps/ScreenShareApi/src/main/kotlin/Bootstrap.kt new file mode 100644 index 0000000..eecce38 --- /dev/null +++ b/apps/ScreenShareApi/src/main/kotlin/Bootstrap.kt @@ -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) +} diff --git a/apps/ScreenShareApi/src/main/kotlin/Database.kt b/apps/ScreenShareApi/src/main/kotlin/Database.kt new file mode 100644 index 0000000..b03ff77 --- /dev/null +++ b/apps/ScreenShareApi/src/main/kotlin/Database.kt @@ -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 useConnection(block: (Connection) -> T): T = + DriverManager.getConnection(jdbcUrl, user, password).use(block) +} diff --git a/apps/ScreenShareApi/src/main/kotlin/Repositories.kt b/apps/ScreenShareApi/src/main/kotlin/Repositories.kt new file mode 100644 index 0000000..a361767 --- /dev/null +++ b/apps/ScreenShareApi/src/main/kotlin/Repositories.kt @@ -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"), + ) diff --git a/apps/ScreenShareApi/src/main/kotlin/Routing.kt b/apps/ScreenShareApi/src/main/kotlin/Routing.kt index a898376..63e2097 100644 --- a/apps/ScreenShareApi/src/main/kotlin/Routing.kt +++ b/apps/ScreenShareApi/src/main/kotlin/Routing.kt @@ -1,12 +1,20 @@ +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.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.* -import io.ktor.server.routing.* +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 @@ -16,10 +24,14 @@ 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(ContentNegotiation) { json() } + install(StatusPages) { + exception { call, error -> + call.respond(HttpStatusCode.fromValue(error.status), ErrorResponse(error.message)) + } } install(CORS) { val configuredOrigins = this@configureRouting.environment.config @@ -29,95 +41,192 @@ fun Application.configureRouting() { ?.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, + 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(status = "ok")) + get("/health") { call.respond(HealthResponse("ok")) } + + post("/api/auth/login") { + val request = call.receive() + val user = UserRepository.findByUsername(request.username.normalizedUsername()) + ?.takeIf { BCrypt.checkpw(request.password, it.passwordHash) } + ?: throw ApiException("用户名或密码错误", 401) + call.respond(authResponse(user)) } - post("/api/rooms/{roomId}/token") { - val roomName = call.parameters["roomId"]?.trim().orEmpty() - if (!ROOM_NAME.matches(roomName)) { + post("/api/auth/register") { + val request = call.receive() + 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() + 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.BadRequest, - ErrorResponse("roomId must contain 3-64 letters, numbers, underscores, or hyphens"), + HttpStatusCode.Created, + InvitationResponse(code, type.name, expiresAt.toString()), ) - return@post } - val request = call.receive() - 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 + post("/api/rooms") { + val user = call.requireCurrentUser() + val title = call.receive().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)) } - val participantName = request.participantName.trim() - if (participantName.length !in 1..64) { - call.respond(HttpStatusCode.BadRequest, ErrorResponse("participantName must be 1-64 characters")) - return@post + 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)) } - 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 + post("/api/rooms/{roomId}/join") { + val user = call.requireCurrentUser() + call.respond(RoomMemberResponse(RoomRepository.joinAsViewer(call.roomId(), user.id))) } - val identity = "dev-${UUID.randomUUID()}" - val token = LiveKitTokenIssuer(apiKey, apiSecret).issue( - roomName = roomName, - identity = identity, - participantName = participantName, - role = 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().role.lowercase()) }.getOrNull() + ?: throw ApiException("角色必须是 host、publisher 或 viewer", 400) + call.respond(RoomMemberResponse(RoomRepository.updateRole(roomId, targetUserId, role))) + } - call.respond( - TokenResponse( - roomName = roomName, - participantIdentity = identity, - participantName = participantName, - livekitUrl = publicUrl, - token = token, - role = role.name, - ), - ) + 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 token = LiveKitTokenIssuer(liveKit.apiKey, liveKit.apiSecret).issue( + roomName = room.id.toString(), + identity = user.id.toString(), + participantName = user.displayName, + role = membership.role, + ) + call.respond( + TokenResponse( + roomName = room.id.toString(), + participantIdentity = user.id.toString(), + participantName = user.displayName, + livekitUrl = liveKit.publicUrl, + token = token, + 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 -data class HealthResponse(val status: String) +private fun io.ktor.server.application.ApplicationCall.requireCurrentUser(): User = + UserRepository.findById(currentUserId()) ?: throw ApiException("用户不存在", 401) -@Serializable -data class TokenRequest( - val participantName: String, - val role: String = "viewer", -) +private fun io.ktor.server.application.ApplicationCall.requireAdministrator(): User = + requireCurrentUser().takeIf { it.isAdmin } ?: throw ApiException("需要管理员权限", 403) -@Serializable -data class TokenResponse( +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 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, @@ -126,80 +235,25 @@ data class TokenResponse( 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 { +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, - ), - ), + LiveKitClaims(apiKey, identity, participantName, now, now + 600, VideoGrant(true, roomName, canPublish, true, canPublish)), ) val header = base64Url("""{"alg":"HS256","typ":"JWT"}""") - val body = base64Url(payload) - val unsignedToken = "$header.$body" + 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)}" + 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, -) +@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) diff --git a/apps/ScreenShareApi/src/main/kotlin/Security.kt b/apps/ScreenShareApi/src/main/kotlin/Security.kt new file mode 100644 index 0000000..d2cf403 --- /dev/null +++ b/apps/ScreenShareApi/src/main/kotlin/Security.kt @@ -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()?.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() diff --git a/apps/ScreenShareApi/src/main/resources/application.local.example.yaml b/apps/ScreenShareApi/src/main/resources/application.local.example.yaml index ea51477..1cafc2c 100644 --- a/apps/ScreenShareApi/src/main/resources/application.local.example.yaml +++ b/apps/ScreenShareApi/src/main/resources/application.local.example.yaml @@ -10,3 +10,19 @@ livekit: apiKey: devkey apiSecret: replace-with-a-long-random-secret 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: 管理员 diff --git a/apps/ScreenShareApi/src/main/resources/db/migration/V1__create_core_schema.sql b/apps/ScreenShareApi/src/main/resources/db/migration/V1__create_core_schema.sql new file mode 100644 index 0000000..9a9d967 --- /dev/null +++ b/apps/ScreenShareApi/src/main/resources/db/migration/V1__create_core_schema.sql @@ -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); diff --git a/apps/ScreenShareApi/src/main/resources/db/migration/V2__add_administration_and_invitations.sql b/apps/ScreenShareApi/src/main/resources/db/migration/V2__add_administration_and_invitations.sql new file mode 100644 index 0000000..3782865 --- /dev/null +++ b/apps/ScreenShareApi/src/main/resources/db/migration/V2__add_administration_and_invitations.sql @@ -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); diff --git a/apps/ScreenShareWeb/src/App.css b/apps/ScreenShareWeb/src/App.css index 59eae02..41a536b 100644 --- a/apps/ScreenShareWeb/src/App.css +++ b/apps/ScreenShareWeb/src/App.css @@ -4,6 +4,53 @@ 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 { margin-bottom: 32px; } @@ -195,6 +242,10 @@ button:disabled { flex-wrap: wrap; } + .app-header, .admin-panel { + display: grid; + } + .share-button { margin-left: 0; } diff --git a/apps/ScreenShareWeb/src/App.tsx b/apps/ScreenShareWeb/src/App.tsx index 1e613a1..61b7e4b 100644 --- a/apps/ScreenShareWeb/src/App.tsx +++ b/apps/ScreenShareWeb/src/App.tsx @@ -4,6 +4,15 @@ import './App.css' type Role = 'host' | 'publisher' | 'viewer' +type User = { + id: string + username: string + displayName: string + isAdmin: boolean +} + +type Session = { token: string; user: User } + type TokenResponse = { roomName: string participantIdentity: string @@ -13,10 +22,7 @@ type TokenResponse = { role: Role } -type ScreenFeed = { - track: Track - label: string -} +type ScreenFeed = { track: Track; label: string } function ScreenTrack({ track, label }: ScreenFeed) { const mediaContainerRef = useRef(null) @@ -25,18 +31,12 @@ function ScreenTrack({ track, label }: ScreenFeed) { const mediaElement = track.attach() mediaElement.autoplay = true if (mediaElement instanceof HTMLVideoElement) { - // Local preview must be muted for browsers to allow autoplay. mediaElement.muted = true mediaElement.playsInline = true - void mediaElement.play().catch(() => { - // The user can still start playback manually if their browser blocks it. - }) + void mediaElement.play().catch(() => undefined) } mediaContainerRef.current?.append(mediaElement) - - return () => { - mediaElement.remove() - } + return () => mediaElement.remove() }, [track]) return ( @@ -47,60 +47,138 @@ function ScreenTrack({ track, label }: ScreenFeed) { ) } +async function apiRequest(path: string, options: RequestInit = {}, token?: string): Promise { + 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() { const roomRef = useRef(null) - const [roomName, setRoomName] = useState('demo-room') - const [participantName, setParticipantName] = useState('') - const [role, setRole] = useState('publisher') + const [session, setSession] = useState(null) + const [authMode, setAuthMode] = useState<'login' | 'register'>('login') + 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 [roomRole, setRoomRole] = useState(null) const [isSharing, setIsSharing] = useState(false) const [remoteCount, setRemoteCount] = useState(0) const [localScreen, setLocalScreen] = useState(null) const [remoteScreens, setRemoteScreens] = useState([]) + const [createdInvite, setCreatedInvite] = useState('') const [error, setError] = useState('') useEffect(() => { - return () => { - roomRef.current?.disconnect() - } + const stored = localStorage.getItem('screen-share-session') + if (!stored) return + const saved = JSON.parse(stored) as Session + void apiRequest('/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) { setRemoteCount(room.remoteParticipants.size) } - async function joinRoom() { - const normalizedRoom = roomName.trim() - const normalizedName = participantName.trim() + async function submitAuth() { + setError('') + try { + const body = authMode === 'login' + ? { username, password } + : { username, password, displayName, inviteCode } + const result = await apiRequest('/api/auth/' + authMode, { + method: 'POST', + body: JSON.stringify(body), + }) + saveSession(result) + setPassword('') + setInviteCode('') + } catch (cause) { + setError(cause instanceof Error ? cause.message : '认证失败') + } + } - if (!/^[A-Za-z0-9_-]{3,64}$/.test(normalizedRoom)) { - setError('房间号只能包含 3–64 个字母、数字、下划线或连字符。') + 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 } - if (!normalizedName) { - setError('请输入显示名称。') + 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('') setConnectionState('connecting') try { - const response = await fetch(`/api/rooms/${encodeURIComponent(normalizedRoom)}/token`, { + await apiRequest('/api/rooms/' + encodeURIComponent(targetRoomId) + '/join', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ participantName: normalizedName, role }), - }) - const payload = (await response.json()) as TokenResponse | { message: string } - - if (!response.ok || !('token' in payload)) { - throw new Error('message' in payload ? payload.message : '无法获取房间凭证。') - } + body: '{}', + }, session.token) + const payload = await apiRequest('/api/rooms/' + encodeURIComponent(targetRoomId) + '/token', { + method: 'POST', + body: '{}', + }, session.token) roomRef.current?.disconnect() setLocalScreen(null) setRemoteScreens([]) - const room = new Room() room.on(RoomEvent.ParticipantConnected, () => 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) { setRemoteScreens((screens) => [ ...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, () => { setConnectionState('idle') + setRoomRole(null) setIsSharing(false) setRemoteCount(0) setLocalScreen(null) @@ -137,122 +216,110 @@ function App() { await room.connect(payload.livekitUrl, payload.token) roomRef.current = room + setRoomId(targetRoomId) + setRoomRole(payload.role) refreshRemoteCount(room) setConnectionState('connected') } catch (cause) { setConnectionState('idle') - setError(cause instanceof Error ? cause.message : '连接房间时发生未知错误。') + setError(cause instanceof Error ? cause.message : '连接房间失败') } } async function toggleScreenShare() { - const room = roomRef.current - if (!room) { - return - } - - setError('') + if (!roomRef.current) return try { - await room.localParticipant.setScreenShareEnabled(!isSharing) + await roomRef.current.localParticipant.setScreenShareEnabled(!isSharing) } catch (cause) { - setError(cause instanceof Error ? cause.message : '无法切换屏幕共享。') + setError(cause instanceof Error ? cause.message : '无法切换屏幕共享') } } function leaveRoom() { roomRef.current?.disconnect() roomRef.current = null - setConnectionState('idle') - setIsSharing(false) - setRemoteCount(0) - setLocalScreen(null) - setRemoteScreens([]) + } + + function logout() { + leaveRoom() + localStorage.removeItem('screen-share-session') + setSession(null) + setCreatedInvite('') } const connected = connectionState === 'connected' - const canShare = role === 'host' || role === 'publisher' + const canShare = roomRole === 'host' || roomRole === 'publisher' + + if (!session) { + return ( +
+
+

SCREEN SHARE

+

{authMode === 'login' ? '登录' : '使用邀请码注册'}

+
+
+ + + {authMode === 'register' && ( + <> + + + + )} + + + {error &&

{error}

} +
+
+ ) + } return (
-
-

LIVEKIT · 开发环境

-

屏幕共享房间

-

浏览器直接连接 LiveKit,Ktor 仅签发短期访问凭证。

+
+
+

LIVEKIT · 已登录

+

屏幕共享房间

+

当前用户:{session.user.displayName}({session.user.isAdmin ? '管理员' : '普通用户'})

+
+
-
- - - + {session.user.isAdmin && ( +
+ 管理员邀请码 + + + {createdInvite && {createdInvite}} +
+ )} - {!connected ? ( - - ) : ( - - )} +
+ + + + {!connected + ? + : }
{error &&

{error}

}
{connected ? '已连接' : '未连接'} + {roomRole ? '房间角色:' + roomRole : '尚未加入房间'} 其他参与者:{remoteCount} - {connected && ( - - )} - {connected && !canShare && Viewer 没有发布权限} + {connected && }
-
-

我的屏幕

-
- {localScreen ? :

尚未共享屏幕

} -
-
-
-

远端屏幕

-
- {remoteScreens.length === 0 - ?

等待其他参与者共享屏幕

- : remoteScreens.map((screen) => ( - - ))} -
-
+

我的屏幕

{localScreen ? :

尚未共享屏幕

}
+

远端屏幕

{remoteScreens.length === 0 ?

等待其他参与者共享屏幕

: remoteScreens.map((screen) => )}
- -

- 当前角色由客户端请求,仅用于本地验证。生产环境必须由应用 JWT 与房间成员权限决定角色。 -

) } diff --git a/docker-compose.yml b/docker-compose.yml index ac46efd..db4f158 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,8 +36,21 @@ services: LIVEKIT_API_KEY: ${LIVEKIT_API_KEY} LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET} 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: - - livekit + livekit: + condition: service_started + postgres: + condition: service_healthy ports: - "8080:8080"