diff --git a/bot/src/classes/RedisClient.ts b/bot/src/classes/RedisClient.ts index 0d29a5d..9089f42 100644 --- a/bot/src/classes/RedisClient.ts +++ b/bot/src/classes/RedisClient.ts @@ -72,11 +72,14 @@ class RedisClientClass { if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); const guild = await getGuildById(data.serverId); if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + if (!(await this.isMember(guild, data.userId))) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); let player = lavalinkManager.getPlayer(guild.id); const voiceChannel = await getVoiceChannelById(guild, data.userId); if (!player) { if (!voiceChannel) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); player = (await channelJoin(guild, voiceChannel.id)).player; + } else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) { + return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." })); } if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); await lavalinkManager.search(guild.id, data.track.url, data.userId, player); @@ -88,11 +91,14 @@ class RedisClientClass { if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); const guild = await getGuildById(data.serverId); if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + if (!(await this.isMember(guild, data.userId))) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); let player = lavalinkManager.getPlayer(guild.id); const voiceChannel = await getVoiceChannelById(guild, data.userId); if (!player) { if (!voiceChannel) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); player = (await channelJoin(guild, voiceChannel.id)).player; + } else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) { + return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." })); } if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); await lavalinkManager.search(guild.id, data.playlistUrl, data.userId, player); @@ -102,8 +108,10 @@ class RedisClientClass { const resultKey = `player:now:${data.requestId}`; if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); + const nowGuild = await getGuildById(data.serverId); + if (!nowGuild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + if (!(await this.isMember(nowGuild, data.userId))) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); const player = lavalinkManager.getPlayer(data.serverId); - // if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." })); await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, botPlayer: !!player, @@ -118,8 +126,10 @@ class RedisClientClass { const resultKey = `queue:list:${data.requestId}`; if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); + const qlGuild = await getGuildById(data.serverId); + if (!qlGuild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + if (!(await this.isMember(qlGuild, data.userId))) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); const player = lavalinkManager.getPlayer(data.serverId); - // if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." })); await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] })); } if (data.action === "queue_set") { @@ -152,6 +162,12 @@ class RedisClientClass { // queue[0]은 현재 재생중인 곡이므로 실제 대기열은 queue[1]부터 시작 // numIndex는 대기열(queue[1]~) 기준이므로 실제 splice 위치�� numIndex+1 if (numIndex >= context.player.queue.length - 1) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index가 대기열 범위를 초과합니다." })); + // 인덱스 신뢰 대신, 클라이언트가 지우려던 곡(encoded)과 실제 대상이 같은지 확인. + // SSE로 큐가 갱신되는 찰나 인덱스가 밀려 다른 곡이 삭제되는 것을 방지. + const removeTarget = context.player.queue[numIndex + 1]; + if (data.encoded && removeTarget?.encoded && removeTarget.encoded !== data.encoded) { + return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "대기열이 변경되었습니다. 새로고침 후 다시 시도해주세요." })); + } const [removedTrack] = context.player.queue.splice(numIndex + 1, 1); await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, removedTrack })); context.player.setMsg(); @@ -232,6 +248,16 @@ class RedisClientClass { Logger.log(`[Redis Pub] bot -> site 전송: ${event}`); } + /** + * 요청한 userId가 해당 guild의 멤버인지 확인(캐시 우선, 없으면 단건 fetch). + * 대시보드가 보낸 serverId를 그대로 신뢰하지 않기 위한 인가 검증. + */ + private async isMember(guild: Guild, userId: string): Promise { + if (guild.members.cache.has(userId)) return true; + const fetched = await guild.members.fetch(userId).catch(() => null); + return !!fetched; + } + private async getContext(guildId: string, resultKey: string, userId: string): Promise<{ ok: true; guild: Guild; @@ -243,6 +269,11 @@ class RedisClientClass { await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); return { ok: false }; } + // 인가: 요청자가 이 서버의 멤버여야 함 (남의 서버 제어 차단) + if (!(await this.isMember(guild, userId))) { + await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); + return { ok: false }; + } let player = lavalinkManager.getPlayer(guild.id); const voiceChannel = await getVoiceChannelById(guild, userId); if (!player) { @@ -251,6 +282,10 @@ class RedisClientClass { return { ok: false }; } player = (await channelJoin(guild, voiceChannel.id)).player; + } else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) { + // 이미 재생 중이면 봇과 같은 음성채널에 있는 사람만 조작 가능 + await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 조작할 수 있습니다." })); + return { ok: false }; } if (!player) { await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." })); diff --git a/page/src/app/api/queue/remove/route.ts b/page/src/app/api/queue/remove/route.ts index 523a10b..8248cc1 100644 --- a/page/src/app/api/queue/remove/route.ts +++ b/page/src/app/api/queue/remove/route.ts @@ -12,6 +12,7 @@ import { interface QueueRemoveBody { serverId?: unknown; index?: unknown; + encoded?: unknown; } export async function POST(request: Request) { @@ -37,6 +38,8 @@ export async function POST(request: Request) { serverId: serverIdResult.value, userId, index: indexResult.value, + // 인덱스-트랙 일치 검증용(봇이 대상 encoded 불일치 시 거절). 문자열일 때만 전달. + encoded: typeof bodyResult.data.encoded === "string" ? bodyResult.data.encoded : undefined, }, timeoutMs: 5000, }); diff --git a/page/src/components/player/MainContent.tsx b/page/src/components/player/MainContent.tsx index e1440a3..e30d6ba 100644 --- a/page/src/components/player/MainContent.tsx +++ b/page/src/components/player/MainContent.tsx @@ -55,7 +55,7 @@ export default function MainContent({ } let endpoint = ""; - const bodyData: Record = { serverId: selectedServer.id, userId }; + const bodyData: Record = { serverId: selectedServer.id }; if (actionType === 'player_play') { endpoint = "/api/player/play"; bodyData.track = track; @@ -108,8 +108,16 @@ export default function MainContent({ setIsFetching(true); const cached = sessionStorage.getItem("filtered_servers"); if (cached) { - setServers(JSON.parse(cached)); - setIsFetching(false); + try { + const parsed = JSON.parse(cached); + if (Array.isArray(parsed)) { + setServers(parsed); + setIsFetching(false); + } + } catch { + // 캐시 손상 시 무시하고 아래 fetch로 새로 받는다. + sessionStorage.removeItem("filtered_servers"); + } } fetch("/api/servers") @@ -162,7 +170,7 @@ export default function MainContent({ const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0; const renderTrackCard = (track: SearchTrack) => ( -
+
{track.thumbnail && {track.title}}