[보안/인증] - 모든 player/queue API 라우트에 세션 가드 추가 (이전: /api/servers 만 보호) - NextAuth 환경변수 부팅 시점 검증, NEXTAUTH_SECRET 명시 - next.config.ts CSP/보안 헤더 추가, 잘못된 allowedDevOrigins 제거 - Redis 호스트 하드코딩 IP 제거(필수 env 로 강제) [안정성] - 봇 RPC 패턴(@/lib/api) 공용화: crypto.randomUUID requestId, JSON.parse 안전, EXPIRE 자동, 폴링 백오프 - SSE(@/lib/sse) 공용화: subscriber error 처리, JSON.parse 안전, 30초 keep-alive, abort/에러 정리 - pause API 양 끝(boolean) 정상화: 프론트 String() 캐스트 + 백엔드 .trim().toLowerCase() 비교 제거 - 봇 RedisClient: isPaused/index/seek/volume falsy 거부 → typeof 검사로 교체(0/false 정상 허용) [타입/품질] - next-auth 모듈 보강 → session.user.id, session.accessToken 타입 안전 - DiscordServer/Track/SearchTrack 공용 타입 도입, 컴포넌트 any 제거 - BigInt permissions 안전 검증(타입 가드) - Logger: NODE_ENV 게이트, error → stderr, ISO 기반 안전 timestamp - tsconfig target → ES2020 (BigInt 리터럴) [취약점] - next 16.2.2 → 16.2.4 (DoS/postcss XSS 패치) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
285 lines
17 KiB
TypeScript
285 lines
17 KiB
TypeScript
import { Redis } from "ioredis";
|
||
import { Config } from "../utils/Config";
|
||
import { Logger } from "../utils/Logger";
|
||
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
|
||
import { Spotify } from "../utils/api/Spotify";
|
||
import { lavalinkManager } from "../index";
|
||
import { getGuildById, getVoiceChannelById } from "../utils/music/Channel";
|
||
import { channelJoin } from "../commands/join";
|
||
import { GuildPlayer } from "./GuildPlayer";
|
||
import { Guild, VoiceChannel } from "discord.js";
|
||
import { SongItem } from "../types/Track";
|
||
|
||
type SubAction =
|
||
"search" |
|
||
"player_now" |
|
||
"player_play" |
|
||
"player_playlist" |
|
||
"player_paused" |
|
||
"player_skip" |
|
||
"player_seek" |
|
||
"player_volume" |
|
||
"queue_list" |
|
||
"queue_set" |
|
||
"queue_remove";
|
||
|
||
export const RedisClient = () => {
|
||
if (Config.redis.state) return new RedisClientClass();
|
||
return null;
|
||
}
|
||
|
||
class RedisClientClass {
|
||
public pub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port });
|
||
public sub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port });
|
||
|
||
constructor() {
|
||
this.pub.on("connect", () => {
|
||
Logger.ready(`[Redis Pub] 연결 완료 (말하는 입)`);
|
||
});
|
||
this.sub.on("connect", () => {
|
||
Logger.ready(`[Redis Sub] 연결 완료 (듣는 귀)`);
|
||
});
|
||
|
||
this.sub.subscribe("site-bot", (err, count) => {
|
||
if (err) return Logger.error(`[Redis Sub] 구독 실패: ${err.message}`);
|
||
Logger.log(`[Redis Sub] 'bot-commands' 채널 구독 중... (현재 구독 채널 수: ${count})`);
|
||
});
|
||
|
||
this.sub.on("message", async (ch, msg): Promise<any> => {
|
||
if (ch !== "site-bot") return;
|
||
Logger.log(`[Redis Sub] [Message] 수신: {\n 채널: ${ch}\n 내용: ${msg}\n}`);
|
||
try {
|
||
const data = JSON.parse(msg) as { action: SubAction; requestId: string; userId?: string; [key: string]: any; };
|
||
|
||
if (data.action === "search") {
|
||
const resultKey = `search:${data.requestId}`;
|
||
const spotify: SongItem[] = (await Spotify.getSearchFull(data.query) ?? []).slice(0,10);
|
||
const youtubeMusic: SongItem[] = (await YoutubeMusic.getSearchFull(data.query) ?? []).slice(0,10);
|
||
const youtubeVideo: SongItem[] = (await lavalinkManager.youtubeSearch(data.query) ?? []).slice(0,10).map((video) => ({
|
||
videoId: video.info.identifier,
|
||
url: `https://www.youtube.com/watch?v=${video.info.identifier}`,
|
||
title: video.info.title,
|
||
artist: video.info.author,
|
||
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})`);
|
||
}
|
||
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를 찾을수 없습니다." }));
|
||
const guild = await getGuildById(data.serverId);
|
||
if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
||
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;
|
||
}
|
||
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);
|
||
await this.pub.setex(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를 찾을수 없습니다." }));
|
||
const guild = await getGuildById(data.serverId);
|
||
if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
||
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;
|
||
}
|
||
if (!player) return await this.pub.setex(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: "플레이리스트 추가 완료" }));
|
||
}
|
||
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를 찾을수 없습니다." }));
|
||
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,
|
||
isPlaying: player?.isPlaying,
|
||
isPaused: player?.isPaused,
|
||
position: player?.position,
|
||
volume: player?.volume,
|
||
track: player?.nowTrack ?? null
|
||
}));
|
||
}
|
||
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를 찾을수 없습니다." }));
|
||
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") {
|
||
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를 찾을수 없습니다." }));
|
||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||
if (!context.ok) return;
|
||
const nowTrack = context.player.nowTrack ?? context.player.queue?.[0];
|
||
context.player.queue.length = 0;
|
||
if (nowTrack) context.player.queue.push(nowTrack);
|
||
for (const rawTrack of data.newQueue) {
|
||
if (rawTrack.encoded) context.player.queue.push(rawTrack);
|
||
}
|
||
await this.pub.setex(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를 찾을수 없습니다." }));
|
||
// index 는 number(0 도 유효) — typeof 검증으로 변경.
|
||
if (typeof data.index !== "number") return await this.pub.setex(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보다 크거나 같아야합니다." }));
|
||
// queue[0]은 현재 재생중인 곡이므로 실제 대기열은 queue[1]부터 시작
|
||
// numIndex는 대기열(queue[1]~) 기준이므로 실제 splice 위치<EC9C84><ECB998> numIndex+1
|
||
if (numIndex >= context.player.queue.length - 1) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index가 대기열 범위를 초과합니다." }));
|
||
const [removedTrack] = context.player.queue.splice(numIndex + 1, 1);
|
||
await this.pub.setex(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를 찾을수 없습니다." }));
|
||
// isPaused 는 boolean — false 도 정상 입력. typeof 검증으로 변경.
|
||
if (typeof data.isPaused !== "boolean") return await this.pub.setex(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 }));
|
||
}
|
||
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를 찾을수 없습니다." }));
|
||
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 }));
|
||
}
|
||
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를 찾을수 없습니다." }));
|
||
// seek 는 number(0 도 유효 — 처음으로 되감기) — typeof 검증으로 변경.
|
||
if (typeof data.seek !== "number") return await this.pub.setex(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: "재생중인 노래가 없습니다." }));
|
||
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보다 클수 없습니다." }));
|
||
context.player.seek(numSeek);
|
||
await this.pub.setex(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를 찾을수 없습니다." }));
|
||
// volume 은 number(0 도 유효 — 음소거) — typeof 검증으로 변경.
|
||
if (typeof data.volume !== "number") return await this.pub.setex(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: "재생중인 노래가 없습니다." }));
|
||
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보다 클수 없습니다." }));
|
||
context.player.setVolume(numVolume);
|
||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
|
||
}
|
||
} catch (err) {
|
||
Logger.error(`명령어 처리 중 에러: ${String(err)}`);
|
||
}
|
||
});
|
||
|
||
this.pub.on("error", (err) => {
|
||
Logger.error(`[Redis Pub] [Error] ${err.message}`);
|
||
});
|
||
this.sub.on("error", (err) => {
|
||
Logger.error(`[Redis Sub] [Error] ${err.message}`);
|
||
});
|
||
}
|
||
|
||
public publishState(event: string, data: any) {
|
||
const payload = JSON.stringify({
|
||
event,
|
||
timestamp: Date.now(),
|
||
...data,
|
||
});
|
||
this.pub.publish("bot-site", payload);
|
||
Logger.log(`[Redis Pub] bot -> site 전송: ${event}`);
|
||
}
|
||
|
||
private async getContext(guildId: string, resultKey: string, userId: string): Promise<{
|
||
ok: true;
|
||
guild: Guild;
|
||
voiceChannel: VoiceChannel | null;
|
||
player: GuildPlayer;
|
||
} | { ok: false; }> {
|
||
const guild = await getGuildById(guildId);
|
||
if (!guild) {
|
||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
||
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: "음성채널에 들어가서 이용해주세요." }));
|
||
return { ok: false };
|
||
}
|
||
player = (await channelJoin(guild, voiceChannel.id)).player;
|
||
}
|
||
if (!player) {
|
||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
|
||
return { ok: false };
|
||
}
|
||
return {
|
||
ok: true,
|
||
guild,
|
||
voiceChannel,
|
||
player,
|
||
};
|
||
}
|
||
|
||
public runTest() {
|
||
Logger.debug(`[Redis Test] 3초 뒤에 테스트 통신 시작...`);
|
||
setTimeout(() => {
|
||
// 1. 봇 -> 사이트(웹) 방향 전송 테스트
|
||
this.publishState("TRACK_START", {
|
||
author: "테스트",
|
||
title: "제목",
|
||
duration: 196000,
|
||
});
|
||
|
||
// 2. 사이트(웹) -> 봇 방향 수신 테스트 (가짜 명령을 쏴서 스스로 수신하는지 확인)
|
||
setTimeout(() => {
|
||
const mockCommand = JSON.stringify({ action: "skip", userId: "12345" });
|
||
// 테스트를 위해 본인이 site-bot 채널로 발행해 봅니다.
|
||
this.pub.publish("site-bot", mockCommand);
|
||
}, 1000);
|
||
}, 3000);
|
||
}
|
||
} |