first commit
CI / Type-check & Build (push) Failing after 5m50s
CI / Build & Push image (push) Skipped

This commit is contained in:
ysj7705 committed 2026-08-12 15:01:43 +09:00
commit 69b647145f
58 files changed
+15796

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
import { ADMIN_COOKIE, signAdminToken } from '../../utils/jwt'
export default defineEventHandler(async (event) => {
const body = await readBody<{ username?: string; password?: string }>(event)
const config = useRuntimeConfig()
if (!body?.username || !body?.password) {
throw createError({ statusCode: 400, statusMessage: 'Username and password required' })
}
if (body.username !== config.adminUsername || body.password !== config.adminPassword) {
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
}
const token = await signAdminToken(body.username)
setCookie(event, ADMIN_COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
return { ok: true, username: body.username }
})
+6
View File
@@ -0,0 +1,6 @@
import { ADMIN_COOKIE } from '../../utils/jwt'
export default defineEventHandler(async (event) => {
deleteCookie(event, ADMIN_COOKIE, { path: '/' })
return { ok: true }
})
+18
View File
@@ -0,0 +1,18 @@
import { ADMIN_COOKIE, verifyAdminToken } from '../../utils/jwt'
export default defineEventHandler(async (event) => {
const token =
getCookie(event, ADMIN_COOKIE) ||
getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
try {
const payload = await verifyAdminToken(token)
return { ok: true, username: payload.username }
} catch {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
})
+19
View File
@@ -0,0 +1,19 @@
import { prisma } from '../../utils/db'
import { forceCloseRoom } from '../../utils/runtimeRooms'
export default defineEventHandler(async (event) => {
const roomId = getRouterParam(event, 'roomId')
if (!roomId) {
throw createError({ statusCode: 400, statusMessage: 'Room id required' })
}
const existing = await prisma.room.findUnique({ where: { id: roomId } })
if (!existing) {
throw createError({ statusCode: 404, statusMessage: 'Room not found' })
}
forceCloseRoom(roomId, 'Room deleted by admin')
await prisma.room.delete({ where: { id: roomId } })
return { ok: true }
})
+21
View File
@@ -0,0 +1,21 @@
import { prisma } from '../../utils/db'
import { getMemberCount, getStreamCount } from '../../utils/runtimeRooms'
export default defineEventHandler(async (event) => {
const roomId = getRouterParam(event, 'roomId')
if (!roomId) {
throw createError({ statusCode: 400, statusMessage: 'Room id required' })
}
const room = await prisma.room.findUnique({ where: { id: roomId } })
if (!room) {
throw createError({ statusCode: 404, statusMessage: 'Room not found' })
}
return {
id: room.id,
name: room.name,
memberCount: getMemberCount(room.id),
streamCount: getStreamCount(room.id),
}
})
+37
View File
@@ -0,0 +1,37 @@
import bcrypt from 'bcryptjs'
import { prisma } from '../../utils/db'
export default defineEventHandler(async (event) => {
const roomId = getRouterParam(event, 'roomId')
if (!roomId) {
throw createError({ statusCode: 400, statusMessage: 'Room id required' })
}
const body = await readBody<{ name?: string; password?: string }>(event)
const existing = await prisma.room.findUnique({ where: { id: roomId } })
if (!existing) {
throw createError({ statusCode: 404, statusMessage: 'Room not found' })
}
const data: { name?: string; passwordHash?: string; pwdVersion?: number } = {}
if (body?.name?.trim()) {
data.name = body.name.trim()
}
if (body?.password) {
if (body.password.length < 4) {
throw createError({ statusCode: 400, statusMessage: 'Password must be at least 4 characters' })
}
data.passwordHash = await bcrypt.hash(body.password, 10)
data.pwdVersion = existing.pwdVersion + 1
}
const room = await prisma.room.update({ where: { id: roomId }, data })
return {
id: room.id,
name: room.name,
pwdVersion: room.pwdVersion,
createdAt: room.createdAt.toISOString(),
createdBy: room.createdBy,
}
})
+59
View File
@@ -0,0 +1,59 @@
import bcrypt from 'bcryptjs'
import { prisma } from '../../../utils/db'
import { signRoomAccessToken, verifyRoomAccessToken } from '../../../utils/roomAccess'
export default defineEventHandler(async (event) => {
const roomId = getRouterParam(event, 'roomId')
if (!roomId) {
throw createError({ statusCode: 400, statusMessage: 'Room id required' })
}
const body = await readBody<{
password?: string
clientId?: string
accessToken?: string
}>(event)
if (!body?.clientId) {
throw createError({ statusCode: 400, statusMessage: 'clientId required' })
}
const room = await prisma.room.findUnique({ where: { id: roomId } })
if (!room) {
throw createError({ statusCode: 404, statusMessage: 'Room not found' })
}
const expected = {
roomId: room.id,
clientId: body.clientId,
pwdVersion: room.pwdVersion,
}
if (body.accessToken) {
try {
await verifyRoomAccessToken(body.accessToken, expected)
const accessToken = await signRoomAccessToken(expected)
return {
accessToken,
room: { id: room.id, name: room.name },
}
} catch {
// fall through to password
}
}
if (!body.password) {
throw createError({ statusCode: 401, statusMessage: 'Password required' })
}
const valid = await bcrypt.compare(body.password, room.passwordHash)
if (!valid) {
throw createError({ statusCode: 401, statusMessage: 'Invalid password' })
}
const accessToken = await signRoomAccessToken(expected)
return {
accessToken,
room: { id: room.id, name: room.name },
}
})
+15
View File
@@ -0,0 +1,15 @@
import { prisma } from '../../utils/db'
import { getMemberCount, getStreamCount } from '../../utils/runtimeRooms'
export default defineEventHandler(async () => {
const rooms = await prisma.room.findMany({ orderBy: { createdAt: 'desc' } })
return rooms.map((room) => ({
id: room.id,
name: room.name,
pwdVersion: room.pwdVersion,
createdAt: room.createdAt.toISOString(),
createdBy: room.createdBy,
memberCount: getMemberCount(room.id),
streamCount: getStreamCount(room.id),
}))
})
+38
View File
@@ -0,0 +1,38 @@
import bcrypt from 'bcryptjs'
import { nanoid } from 'nanoid'
import { prisma } from '../../utils/db'
export default defineEventHandler(async (event) => {
const body = await readBody<{ name?: string; password?: string }>(event)
if (!body?.name?.trim() || !body?.password) {
throw createError({ statusCode: 400, statusMessage: 'Name and password required' })
}
if (body.password.length < 4) {
throw createError({ statusCode: 400, statusMessage: 'Password must be at least 4 characters' })
}
const id = nanoid(10)
const passwordHash = await bcrypt.hash(body.password, 10)
const admin = event.context.admin as { username?: string } | undefined
const room = await prisma.room.create({
data: {
id,
name: body.name.trim(),
passwordHash,
pwdVersion: 1,
createdBy: admin?.username || 'admin',
},
})
return {
id: room.id,
name: room.name,
pwdVersion: room.pwdVersion,
createdAt: room.createdAt.toISOString(),
createdBy: room.createdBy,
url: `/room/${room.id}`,
}
})
+37
View File
@@ -0,0 +1,37 @@
import { ADMIN_COOKIE, verifyAdminToken } from '../utils/jwt'
/**
* Protects admin room management endpoints.
* Public: GET /api/rooms/:roomId (meta), POST /api/rooms/:roomId/access
*/
export default defineEventHandler(async (event) => {
const url = getRequestURL(event)
const path = url.pathname
const method = event.method
if (!path.startsWith('/api/rooms')) return
const isAccess = /\/api\/rooms\/[^/]+\/access$/.test(path) && method === 'POST'
const isPublicMeta = /\/api\/rooms\/[^/]+$/.test(path) && method === 'GET' && path !== '/api/rooms'
if (isAccess || isPublicMeta) return
const needsAuth =
path === '/api/rooms' ||
(/\/api\/rooms\/[^/]+$/.test(path) && (method === 'PATCH' || method === 'DELETE'))
if (!needsAuth) return
const token =
getCookie(event, ADMIN_COOKIE) ||
getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
try {
event.context.admin = await verifyAdminToken(token)
} catch {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
})
+220
View File
@@ -0,0 +1,220 @@
import type { NitroApp } from 'nitropack'
import { Server as Engine } from 'engine.io'
import { Server } from 'socket.io'
import { defineEventHandler } from 'h3'
import { prisma } from '../utils/db'
import { verifyRoomAccessToken } from '../utils/roomAccess'
import { getMaxConcurrentStreams, getMaxRoomMembers } from '../utils/rooms'
import {
broadcastRoomState,
buildRoomState,
getOrCreateRuntimeRoom,
removeViewerWatches,
setIO,
stopStreamsByClient,
type RoomSession,
} from '../utils/runtimeRooms'
export default defineNitroPlugin((nitroApp: NitroApp) => {
const engine = new Engine()
const io = new Server({
cors: { origin: true, credentials: true },
path: '/socket.io/',
})
io.bind(engine)
setIO(io)
io.on('connection', (socket) => {
let currentRoomId: string | null = null
let currentClientId: string | null = null
const cleanup = () => {
if (!currentRoomId || !currentClientId) return
const room = getOrCreateRuntimeRoom(currentRoomId)
const session = room.sessions.get(currentClientId)
if (session && session.socketId === socket.id) {
stopStreamsByClient(room, currentClientId)
removeViewerWatches(room, currentClientId)
room.sessions.delete(currentClientId)
io.to(currentRoomId).emit('peer:left', { clientId: currentClientId })
broadcastRoomState(currentRoomId)
if (room.sessions.size === 0) {
// keep empty runtime room until deleted from DB; optional cleanup
}
}
currentRoomId = null
currentClientId = null
}
socket.on('room:join', async (payload, ack) => {
try {
const { roomId, clientId, nickname, accessToken } = payload || {}
if (!roomId || !clientId || !nickname?.trim() || !accessToken) {
ack?.({ ok: false, error: 'Invalid join payload' })
return
}
const dbRoom = await prisma.room.findUnique({ where: { id: roomId } })
if (!dbRoom) {
ack?.({ ok: false, error: 'Room not found' })
return
}
await verifyRoomAccessToken(accessToken, {
roomId,
clientId,
pwdVersion: dbRoom.pwdVersion,
})
if (currentRoomId && currentClientId) {
cleanup()
}
const room = getOrCreateRuntimeRoom(roomId)
const existing = room.sessions.get(clientId)
if (existing && existing.socketId !== socket.id) {
const oldSocket = io.sockets.sockets.get(existing.socketId)
oldSocket?.emit('room:force-close', { reason: 'Replaced by new session' })
oldSocket?.disconnect(true)
stopStreamsByClient(room, clientId)
removeViewerWatches(room, clientId)
room.sessions.delete(clientId)
}
const maxMembers = getMaxRoomMembers()
if (!room.sessions.has(clientId) && room.sessions.size >= maxMembers) {
ack?.({ ok: false, error: `Room is full (max ${maxMembers})` })
return
}
const session: RoomSession = {
clientId,
socketId: socket.id,
nickname: nickname.trim().slice(0, 32),
joinedAt: Date.now(),
streamId: null,
}
room.sessions.set(clientId, session)
currentRoomId = roomId
currentClientId = clientId
socket.join(roomId)
ack?.({ ok: true })
socket.emit('room:state', buildRoomState(room))
broadcastRoomState(roomId)
} catch (err) {
const message = err instanceof Error ? err.message : 'Join failed'
ack?.({ ok: false, error: message })
}
})
socket.on('room:leave', () => {
socket.leave(currentRoomId || '')
cleanup()
})
socket.on('stream:start', (payload, ack) => {
if (!currentRoomId || !currentClientId) {
ack?.({ ok: false, error: 'Not in a room' })
return
}
const room = getOrCreateRuntimeRoom(currentRoomId)
const session = room.sessions.get(currentClientId)
if (!session) {
ack?.({ ok: false, error: 'Session missing' })
return
}
if (session.streamId) {
ack?.({ ok: false, error: 'Already streaming' })
return
}
const maxStreams = getMaxConcurrentStreams()
if (room.streams.size >= maxStreams) {
ack?.({ ok: false, error: `Max concurrent streams is ${maxStreams}` })
return
}
const streamId = payload?.streamId || `${currentClientId}-${Date.now()}`
session.streamId = streamId
room.streams.set(streamId, {
streamId,
broadcasterId: currentClientId,
broadcasterNickname: session.nickname,
startedAt: Date.now(),
})
ack?.({ ok: true })
broadcastRoomState(currentRoomId)
})
socket.on('stream:stop', (payload) => {
if (!currentRoomId || !currentClientId) return
const room = getOrCreateRuntimeRoom(currentRoomId)
const session = room.sessions.get(currentClientId)
const streamId = payload?.streamId || session?.streamId
if (!streamId) return
room.streams.delete(streamId)
if (session?.streamId === streamId) session.streamId = null
for (const set of room.watchMap.values()) {
set.delete(streamId)
}
broadcastRoomState(currentRoomId)
})
socket.on('watch:start', (payload) => {
if (!currentRoomId || !currentClientId || !payload?.streamId) return
const room = getOrCreateRuntimeRoom(currentRoomId)
if (!room.streams.has(payload.streamId)) return
let set = room.watchMap.get(currentClientId)
if (!set) {
set = new Set()
room.watchMap.set(currentClientId, set)
}
set.add(payload.streamId)
broadcastRoomState(currentRoomId)
})
socket.on('watch:stop', (payload) => {
if (!currentRoomId || !currentClientId || !payload?.streamId) return
const room = getOrCreateRuntimeRoom(currentRoomId)
room.watchMap.get(currentClientId)?.delete(payload.streamId)
broadcastRoomState(currentRoomId)
})
const relay = (eventName: 'webrtc:offer' | 'webrtc:answer' | 'webrtc:ice') => {
socket.on(eventName, (payload) => {
if (!currentRoomId || !currentClientId || !payload?.to) return
const room = getOrCreateRuntimeRoom(currentRoomId)
const target = room.sessions.get(payload.to)
if (!target) return
io.to(target.socketId).emit(eventName, {
...payload,
from: currentClientId,
})
})
}
relay('webrtc:offer')
relay('webrtc:answer')
relay('webrtc:ice')
socket.on('disconnect', () => {
cleanup()
})
})
nitroApp.router.use(
'/socket.io/',
defineEventHandler({
handler(event) {
engine.handleRequest(event.node.req, event.node.res)
event._handled = true
},
websocket: {
open(peer) {
// @ts-expect-error engine.io peer bridge
engine.prepare(peer._internal.nodeReq)
// @ts-expect-error engine.io peer bridge
engine.onWebSocket(peer._internal.nodeReq, peer._internal.nodeReq.socket, peer.websocket)
},
},
}),
)
})
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
+24
View File
@@ -0,0 +1,24 @@
import { SignJWT, jwtVerify } from 'jose'
function getSecret() {
const config = useRuntimeConfig()
return new TextEncoder().encode(config.jwtSecret)
}
export async function signAdminToken(username: string) {
return new SignJWT({ role: 'admin', username })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(getSecret())
}
export async function verifyAdminToken(token: string) {
const { payload } = await jwtVerify(token, getSecret())
if (payload.role !== 'admin') {
throw createError({ statusCode: 401, statusMessage: 'Invalid admin token' })
}
return payload as { role: string; username: string }
}
export const ADMIN_COOKIE = 'sejong_admin_token'
+42
View File
@@ -0,0 +1,42 @@
import { SignJWT, jwtVerify } from 'jose'
export interface RoomAccessClaims {
roomId: string
clientId: string
pwdVersion: number
}
function getSecret() {
const config = useRuntimeConfig()
return new TextEncoder().encode(config.jwtSecret)
}
export async function signRoomAccessToken(claims: RoomAccessClaims) {
const config = useRuntimeConfig()
const days = config.roomAccessTtlDays || 7
return new SignJWT({ ...claims, typ: 'room_access' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${days}d`)
.sign(getSecret())
}
export async function verifyRoomAccessToken(
token: string,
expected: { roomId: string; clientId: string; pwdVersion: number },
) {
const { payload } = await jwtVerify(token, getSecret())
if (payload.typ !== 'room_access') {
throw new Error('Invalid token type')
}
if (payload.roomId !== expected.roomId) {
throw new Error('Room mismatch')
}
if (payload.clientId !== expected.clientId) {
throw new Error('Client mismatch')
}
if (Number(payload.pwdVersion) !== expected.pwdVersion) {
throw new Error('Password changed')
}
return payload as unknown as RoomAccessClaims
}
+7
View File
@@ -0,0 +1,7 @@
export function getMaxRoomMembers() {
return useRuntimeConfig().maxRoomMembers || 8
}
export function getMaxConcurrentStreams() {
return useRuntimeConfig().maxConcurrentStreams || 4
}
+117
View File
@@ -0,0 +1,117 @@
import type { ActiveStream, Participant, RoomState, WatchMap } from '~/types/room'
import type { Server } from 'socket.io'
export interface RoomSession {
clientId: string
socketId: string
nickname: string
joinedAt: number
streamId: string | null
}
export interface RuntimeRoom {
roomId: string
sessions: Map<string, RoomSession>
streams: Map<string, ActiveStream>
/** viewerClientId -> Set<streamId> */
watchMap: Map<string, Set<string>>
}
const rooms = new Map<string, RuntimeRoom>()
let ioRef: Server | null = null
export function setIO(io: Server) {
ioRef = io
}
export function getIO() {
return ioRef
}
export function getOrCreateRuntimeRoom(roomId: string): RuntimeRoom {
let room = rooms.get(roomId)
if (!room) {
room = {
roomId,
sessions: new Map(),
streams: new Map(),
watchMap: new Map(),
}
rooms.set(roomId, room)
}
return room
}
export function getRuntimeRoom(roomId: string) {
return rooms.get(roomId)
}
export function deleteRuntimeRoom(roomId: string) {
rooms.delete(roomId)
}
export function serializeWatchMap(room: RuntimeRoom): WatchMap {
const out: WatchMap = {}
for (const [viewerId, streamIds] of room.watchMap) {
out[viewerId] = [...streamIds]
}
return out
}
export function buildRoomState(room: RuntimeRoom): RoomState {
const participants: Participant[] = [...room.sessions.values()].map((s) => ({
clientId: s.clientId,
nickname: s.nickname,
joinedAt: s.joinedAt,
isStreaming: Boolean(s.streamId),
}))
return {
roomId: room.roomId,
participants,
activeStreams: [...room.streams.values()],
watchMap: serializeWatchMap(room),
}
}
export function broadcastRoomState(roomId: string) {
const room = rooms.get(roomId)
const io = ioRef
if (!room || !io) return
io.to(roomId).emit('room:state', buildRoomState(room))
}
export function forceCloseRoom(roomId: string, reason = 'Room deleted') {
const io = ioRef
if (io) {
io.to(roomId).emit('room:force-close', { reason })
io.in(roomId).disconnectSockets(true)
}
deleteRuntimeRoom(roomId)
}
export function removeViewerWatches(room: RuntimeRoom, clientId: string) {
room.watchMap.delete(clientId)
for (const [, set] of room.watchMap) {
// no-op for stream ownership
}
}
export function stopStreamsByClient(room: RuntimeRoom, clientId: string) {
const session = room.sessions.get(clientId)
if (session?.streamId) {
room.streams.delete(session.streamId)
for (const [, set] of room.watchMap) {
set.delete(session.streamId)
}
session.streamId = null
}
}
export function getMemberCount(roomId: string) {
return rooms.get(roomId)?.sessions.size ?? 0
}
export function getStreamCount(roomId: string) {
return rooms.get(roomId)?.streams.size ?? 0
}