From 1efa55c3ec94ca84fc7b47c921fe94baa9fe8f29 Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Sat, 12 Sep 2026 22:49:56 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(page+bot):=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=A7=80=EC=A0=81=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81=20-?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EB=A9=A4=EB=B2=84=EC=8B=AD=C2=B7?= =?UTF-8?q?=EA=B0=99=EC=9D=80=EC=9D=8C=EC=84=B1=EC=B1=84=EB=84=90=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80(1,2),=20SSE=20=EA=B3=B5=EC=9C=A0=EA=B5=AC?= =?UTF-8?q?=EB=8F=85=20fan-out(3),=20botRpc=20=EC=A6=89=EC=8B=9C=ED=8F=B4?= =?UTF-8?q?=EB=A7=81(4),=20=ED=81=90=EC=82=AD=EC=A0=9C=20encoded=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D(6),=20=EC=A7=84=ED=96=89=EB=B0=94=20anchor?= =?UTF-8?q?=20=ED=83=80=EC=9D=B4=EB=A8=B8(7),=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=20=EA=B0=80=EB=93=9C(8),=20key=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94(9),=20=EB=B3=BC=EB=A5=A8=20=EB=A1=A4?= =?UTF-8?q?=EB=B0=B1(10),=20SSE=20=EC=9E=90=EB=8F=99=EC=9E=AC=EC=97=B0?= =?UTF-8?q?=EA=B2=B0(11),=20body=20userId=20=EC=A0=95=EB=A6=AC(12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/src/classes/RedisClient.ts | 39 +++++- page/src/app/api/queue/remove/route.ts | 3 + page/src/components/player/MainContent.tsx | 16 ++- page/src/components/player/PlayerBar.tsx | 58 ++++---- page/src/components/player/QueueSidebar.tsx | 13 +- page/src/lib/api.ts | 13 +- page/src/lib/sse.ts | 139 ++++++++++---------- 7 files changed, 165 insertions(+), 116 deletions(-) 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}}