diff --git a/bot/src/classes/RedisClient.ts b/bot/src/classes/RedisClient.ts index 0d29a5d..906d0e9 100644 --- a/bot/src/classes/RedisClient.ts +++ b/bot/src/classes/RedisClient.ts @@ -63,48 +63,56 @@ class RedisClientClass { thumbnail: video.info.artworkUrl ?? "", duration: video.info.length, })); - await this.pub.setex(resultKey, 60, JSON.stringify({ spotify, youtubeMusic, youtubeVideo })); - Logger.log(`[Redis Pub] [setex] 결과 저장: (${resultKey})`); + await this.reply(resultKey, 60, JSON.stringify({ spotify, youtubeMusic, youtubeVideo })); + Logger.log(`[Redis Pub] [reply] 결과 저장: (${resultKey})`); } if (data.action === "player_play") { const resultKey = `player:play:${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를 찾을수 없습니다." })); + if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(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 (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + if (!(await this.isMember(guild, data.userId))) return await this.reply(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: "음성채널에 들어가서 이용해주세요." })); + if (!voiceChannel) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); player = (await channelJoin(guild, voiceChannel.id)).player; + } else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) { + return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." })); } - if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); + if (!player) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); await lavalinkManager.search(guild.id, data.track.url, data.userId, player); - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" })); + await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" })); } if (data.action === "player_playlist") { const resultKey = `player:playlist:${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를 찾을수 없습니다." })); + if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(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 (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + if (!(await this.isMember(guild, data.userId))) return await this.reply(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: "음성채널에 들어가서 이용해주세요." })); + if (!voiceChannel) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); player = (await channelJoin(guild, voiceChannel.id)).player; + } else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) { + return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." })); } - if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); + if (!player) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); await lavalinkManager.search(guild.id, data.playlistUrl, data.userId, player); - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" })); + await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" })); } if (data.action === "player_now") { 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를 찾을수 없습니다." })); + if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); + const nowGuild = await getGuildById(data.serverId); + if (!nowGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + if (!(await this.isMember(nowGuild, data.userId))) return await this.reply(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({ + await this.reply(resultKey, 60, JSON.stringify({ success: true, botPlayer: !!player, isPlaying: player?.isPlaying, @@ -116,17 +124,19 @@ class RedisClientClass { } if (data.action === "queue_list") { 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를 찾을수 없습니다." })); + if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); + const qlGuild = await getGuildById(data.serverId); + if (!qlGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + if (!(await this.isMember(qlGuild, data.userId))) return await this.reply(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) ?? [] })); + await this.reply(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] })); } if (data.action === "queue_set") { const resultKey = `queue:set:${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를 찾을수 없습니다." })); - if (!data.newQueue) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "newQueue를 찾을수 없습니다." })); + if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); + if (!data.newQueue) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "newQueue를 찾을수 없습니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; const nowTrack = context.player.nowTrack ?? context.player.queue?.[0]; @@ -135,79 +145,85 @@ class RedisClientClass { for (const rawTrack of data.newQueue) { if (rawTrack.encoded) context.player.queue.push(rawTrack); } - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true })); + await this.reply(resultKey, 60, JSON.stringify({ success: true })); context.player.setMsg(); } if (data.action === "queue_remove") { const resultKey = `queue:remove:${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를 찾을수 없습니다." })); + if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); // index 는 number(0 도 유효) — typeof 검증으로 변경. - if (typeof data.index !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 number 이어야 합니다." })); + if (typeof data.index !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index는 number 이어야 합니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; const numIndex = Number(data.index); - if (isNaN(numIndex)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." })); - if (numIndex < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." })); + if (isNaN(numIndex)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." })); + if (numIndex < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." })); // 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가 대기열 범위를 초과합니다." })); + if (numIndex >= context.player.queue.length - 1) return await this.reply(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.reply(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 })); + await this.reply(resultKey, 60, JSON.stringify({ success: true, removedTrack })); context.player.setMsg(); } if (data.action === "player_paused") { const resultKey = `player:paused:${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를 찾을수 없습니다." })); + if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); // isPaused 는 boolean — false 도 정상 입력. typeof 검증으로 변경. - if (typeof data.isPaused !== "boolean") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "isPaused는 boolean 이어야 합니다." })); + if (typeof data.isPaused !== "boolean") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "isPaused는 boolean 이어야 합니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; await context.player.setPause(); - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, paused: context.player.isPaused })); + await this.reply(resultKey, 60, JSON.stringify({ success: true, paused: context.player.isPaused })); } if (data.action === "player_skip") { const resultKey = `player:skip:${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를 찾을수 없습니다." })); + if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; if (context.player.isPlaying) context.player.skip(); - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true })); + await this.reply(resultKey, 60, JSON.stringify({ success: true })); } if (data.action === "player_seek") { const resultKey = `player:seek:${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를 찾을수 없습니다." })); + if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); // seek 는 number(0 도 유효 — 처음으로 되감기) — typeof 검증으로 변경. - if (typeof data.seek !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 number 이어야 합니다." })); + if (typeof data.seek !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek는 number 이어야 합니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; - if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." })); + if (!context.player.isPlaying || !context.player.nowTrack) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." })); const duration = context.player.nowTrack.info.length || 0; const numSeek = Number(data.seek); - if (isNaN(numSeek)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." })); - if (numSeek < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." })); - if (numSeek > duration) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek가 duration보다 클수 없습니다." })); + if (isNaN(numSeek)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." })); + if (numSeek < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." })); + if (numSeek > duration) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek가 duration보다 클수 없습니다." })); context.player.seek(numSeek); - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true })); + await this.reply(resultKey, 60, JSON.stringify({ success: true })); } if (data.action === "player_volume") { const resultKey = `player:volume:${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를 찾을수 없습니다." })); + if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); // volume 은 number(0 도 유효 — 음소거) — typeof 검증으로 변경. - if (typeof data.volume !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 number 이어야 합니다." })); + if (typeof data.volume !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume은 number 이어야 합니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; - if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." })); + if (!context.player.isPlaying || !context.player.nowTrack) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." })); const numVolume = Number(data.volume); - if (isNaN(numVolume)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." })); - if (numVolume < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." })); - if (numVolume > 100) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume이 100보다 클수 없습니다." })); + if (isNaN(numVolume)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." })); + if (numVolume < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." })); + if (numVolume > 100) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume이 100보다 클수 없습니다." })); context.player.setVolume(numVolume); - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true })); + await this.reply(resultKey, 60, JSON.stringify({ success: true })); } } catch (err) { Logger.error(`명령어 처리 중 에러: ${String(err)}`); @@ -222,6 +238,17 @@ class RedisClientClass { }); } + /** + * 사이트로 보내는 RPC 응답 쓰기. + * 기존 SET+폴링 대신, 사이트가 BRPOP 으로 블로킹 대기하도록 리스트에 LPUSH 한다. + * ttlSec 는 사이트가 이미 타임아웃된 뒤 남는 고아 응답을 자동 정리하기 위한 보험. + * (시그니처를 기존 setex(key, ttl, value) 와 동일하게 맞춰 호출부를 기계적으로 교체) + */ + private async reply(key: string, ttlSec: number, value: string): Promise { + await this.pub.lpush(key, value); + await this.pub.expire(key, ttlSec); + } + public publishState(event: string, data: any) { const payload = JSON.stringify({ event, @@ -232,6 +259,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; @@ -240,20 +277,29 @@ class RedisClientClass { } | { ok: false; }> { const guild = await getGuildById(guildId); if (!guild) { - await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + return { ok: false }; + } + // 인가: 요청자가 이 서버의 멤버여야 함 (남의 서버 제어 차단) + if (!(await this.isMember(guild, userId))) { + await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); return { ok: false }; } let player = lavalinkManager.getPlayer(guild.id); const voiceChannel = await getVoiceChannelById(guild, userId); if (!player) { if (!voiceChannel) { - await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); + await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); return { ok: false }; } player = (await channelJoin(guild, voiceChannel.id)).player; + } else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) { + // 이미 재생 중이면 봇과 같은 음성채널에 있는 사람만 조작 가능 + await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 조작할 수 있습니다." })); + return { ok: false }; } if (!player) { - await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." })); + await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." })); return { ok: false }; } return { diff --git a/page/Dockerfile b/page/Dockerfile new file mode 100644 index 0000000..e91008f --- /dev/null +++ b/page/Dockerfile @@ -0,0 +1,21 @@ +# music_bot_v2 대시보드(page/) — Next.js 16 +FROM node:20-alpine +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +# next build 시 라우트 모듈 평가 단계에서 env 미설정이면 throw 되므로, 빌드용 더미 env를 넣는다. +# (실제 값은 런타임에 compose의 environment로 주입 — 아래 값은 빌드 통과용 placeholder) +ENV DISCORD_CLIENT_ID=build \ + DISCORD_CLIENT_SECRET=build \ + NEXTAUTH_SECRET=build \ + NEXTAUTH_URL=http://localhost:3000 \ + REDIS_HOST=127.0.0.1 \ + REDIS_PORT=6379 +RUN npm run build + +EXPOSE 3000 +CMD ["npm", "run", "start"] diff --git a/page/docker-compose.example.yml b/page/docker-compose.example.yml new file mode 100644 index 0000000..b7f0379 --- /dev/null +++ b/page/docker-compose.example.yml @@ -0,0 +1,21 @@ +# music_bot_v2 대시보드(page/) 배포 예시. +# 운영 .5 호스트에서는 /root/bot/ 아래에 이 내용을 두고 `docker compose -f ... up -d --build` 로 띄운다. +# +# ⚠️ 로그인(Discord OAuth) 전제조건: +# Discord 개발자 포털 → music_bot 앱(APPID 1412338882934079498) → OAuth2 → Redirects 에 +# "/api/auth/callback/discord" 를 반드시 등록해야 로그인이 된다. +# (테스트 포트로 띄우면 예: http://192.168.10.5:2960/api/auth/callback/discord) +services: + music_site_test: + build: ./music_bot_v2/page # (테스트 시엔 page-improvements 브랜치 체크아웃 상태) + container_name: music_site_test + restart: always + ports: + - "2960:3000" # 테스트용 포트(다른 사이트와 겹치지 않게) + environment: + - DISCORD_CLIENT_ID=1412338882934079498 + - DISCORD_CLIENT_SECRET= + - NEXTAUTH_SECRET= + - NEXTAUTH_URL=http://192.168.10.5:2960 + - REDIS_HOST=192.168.10.7 # 운영 봇과 같은 Redis(실봇 제어·상태 테스트). 격리 원하면 별도 Redis. + - REDIS_PORT=6379 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/app/api/search/route.ts b/page/src/app/api/search/route.ts index 956db0d..69afb0e 100644 --- a/page/src/app/api/search/route.ts +++ b/page/src/app/api/search/route.ts @@ -20,7 +20,6 @@ export async function GET(request: Request) { query, }, timeoutMs: 10000, - pollIntervalMs: 250, }); return NextResponse.json(body, { status }); } catch (error) { 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}}