page 전체 코드 품질/보안 개선 및 봇 RPC 검증 정합

[보안/인증]
- 모든 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>
This commit is contained in:
2026-04-28 14:56:55 +09:00
parent e5f3b87b1d
commit b670a61192
32 changed files with 1022 additions and 776 deletions

View File

@@ -5,21 +5,16 @@ import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc }
import { ViewMode } from "@/app/page";
// 🌟 [추가됨] 전역 토스트 훅 불러오기
import { useToast } from "@/components/ToastProvider";
import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
interface MainContentProps {
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
selectedServer: any;
setSelectedServer: (server: any) => void;
selectedServer: DiscordServer | null;
setSelectedServer: (server: DiscordServer | null) => void;
searchQuery: string;
setSearchQuery: (query: string) => void;
onSelectServer: (server: any) => void;
}
interface SearchResultsType {
spotify: any[];
youtubeMusic: any[];
youtubeVideo: any[];
onSelectServer: (server: DiscordServer) => void;
}
export default function MainContent({
@@ -36,27 +31,31 @@ export default function MainContent({
// 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기
const { showToast } = useToast();
const [servers, setServers] = useState<any[]>([]);
const [servers, setServers] = useState<DiscordServer[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResultsType>({
const [searchResults, setSearchResults] = useState<SearchResults>({
spotify: [],
youtubeMusic: [],
youtubeVideo: []
});
const [isSearching, setIsSearching] = useState(false);
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: any, playlistUrl?: string) => {
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => {
if (!selectedServer) {
// 🌟 alert -> showToast 교체
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
return;
}
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
if (!userId) {
showToast("로그인이 필요합니다.", "error");
return;
}
let endpoint = "";
let bodyData: any = { serverId: selectedServer.id, userId: userId };
const bodyData: Record<string, unknown> = { serverId: selectedServer.id, userId };
if (actionType === 'player_play') {
endpoint = "/api/player/play";
bodyData.track = track;
@@ -88,13 +87,16 @@ export default function MainContent({
}
};
const getPermissionLabel = (server: any) => {
const getPermissionLabel = (server: DiscordServer | null) => {
if (!server) return "알 수 없음";
if (server.owner) return "👑 서버 주인";
// Discord permissions 는 큰 정수 문자열로 도착함. 숫자/문자열만 받아 안전하게 BigInt 화.
const raw: unknown = server.permissions;
if (typeof raw !== "string" && typeof raw !== "number") return "👤 일반 멤버";
try {
const perms = BigInt(server.permissions);
if ((perms & BigInt(0x8)) === BigInt(0x8)) return "🛠️ 관리자";
if ((perms & BigInt(0x20)) === BigInt(0x20)) return "⚙️ 매니저";
const perms = BigInt(raw);
if ((perms & 0x8n) === 0x8n) return "🛠️ 관리자";
if ((perms & 0x20n) === 0x20n) return "⚙️ 매니저";
return "👤 일반 멤버";
} catch {
return "👤 일반 멤버";
@@ -159,7 +161,7 @@ export default function MainContent({
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
const renderTrackCard = (track: any) => (
const renderTrackCard = (track: SearchTrack) => (
<div key={track.videoId || track.id} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
<div className="aspect-square bg-neutral-700 rounded-md mb-2 relative overflow-hidden shadow-lg">
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
@@ -258,7 +260,7 @@ export default function MainContent({
{/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */}
{viewMode === "SEARCH_RESULT" && (
<div>
<h2 className="text-2xl font-bold mb-2">"{searchQuery}" </h2>
<h2 className="text-2xl font-bold mb-2">&ldquo;{searchQuery}&rdquo; </h2>
<p className="text-neutral-400 mb-8 text-sm"> .</p>
{isSearching ? (

View File

@@ -2,16 +2,17 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react";
import { useSession } from "next-auth/react";
import type { DiscordServer, Track } from "@/types/music";
interface PlayerBarProps {
selectedServer: any;
selectedServer: DiscordServer | null;
}
export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const { data: session } = useSession();
// 재생 상태 관리
const [track, setTrack] = useState<any>(null);
const [track, setTrack] = useState<Track | null>(null);
const [botPlayer, setBotPlayer] = useState<boolean>(false);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isPaused, setIsPaused] = useState<boolean>(false);
@@ -28,7 +29,7 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const fetchNowPlaying = useCallback(async () => {
if (!selectedServer) return;
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
if (!userId) return;
try {
@@ -43,12 +44,12 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const data = await res.json();
if (res.ok && data.success && data.track) {
setBotPlayer(data.botPlayer);
setIsPlaying(data.isPlaying);
setIsPaused(data.isPaused);
setTrack(data.track);
setDuration(data.track.info.length || 0);
setVolume(data.volume ?? 50);
setBotPlayer(Boolean(data.botPlayer));
setIsPlaying(Boolean(data.isPlaying));
setIsPaused(Boolean(data.isPaused));
setTrack(data.track as Track);
setDuration(Number(data.track?.info?.length ?? 0) || 0);
setVolume(typeof data.volume === "number" ? data.volume : 50);
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
if (!isDragging.current) {
setPosition(data.position || 0);
@@ -67,41 +68,53 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
// 2. 초기 로드 및 SSE 실시간 업데이트 수신
useEffect(() => {
if (!selectedServer) return;
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchNowPlaying();
// 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호
const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "player_update") {
fetchNowPlaying();
try {
const data = JSON.parse(event.data);
if (data?.type === "player_update") {
fetchNowPlaying();
}
} catch (err) {
console.warn("SSE JSON 파싱 실패:", err);
}
};
eventSource.onerror = (error) => {
console.error("Player SSE 연결 오류:", error);
eventSource.close();
};
return () => eventSource.close();
}, [selectedServer, fetchNowPlaying]);
// 3. 🌟 로컬 1초 타이머 & 10초 서버 동기화 통합 (재생 중일 때만 작동!)
// isPaused 상태를 ref 로 들고 있어서, interval 콜백이 항상 최신 값을 읽도록 처리.
const isPausedRef = useRef(isPaused);
useEffect(() => {
let localInterval: NodeJS.Timeout;
let syncInterval: NodeJS.Timeout;
isPausedRef.current = isPaused;
}, [isPaused]);
// 노래가 재생 중이고, 유저가 재생바를 잡고 있지 않을 때만 타이머들을 가동합니다.
if (isPlaying && !isDragging.current) {
useEffect(() => {
if (!isPlaying) return;
// ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용)
localInterval = setInterval(() => {
if (!isPaused) setPosition((prev) => {
if (prev >= duration) return duration;
return prev + 1000;
});
}, 1000);
// ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용)
const localInterval = setInterval(() => {
if (isPausedRef.current || isDragging.current) return;
setPosition((prev) => {
if (prev >= duration) return duration;
return prev + 1000;
});
}, 1000);
// ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용)
syncInterval = setInterval(() => {
if (!isPaused) fetchNowPlaying();
}, 10000);
}
// ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용)
const syncInterval = setInterval(() => {
if (isPausedRef.current || isDragging.current) return;
fetchNowPlaying();
}, 10000);
// 일시정지되거나 컴포넌트가 꺼지면 두 타이머 모두 깔끔하게 청소합니다.
return () => {
@@ -117,9 +130,11 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const handleTogglePause = async () => {
if (!selectedServer || !track) return;
if (!isPlaying) return;
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
if (!userId) return;
const nextPaused = !isPaused;
// UI 즉각 반영 (Optimistic UI)
setIsPaused(!isPaused);
setIsPaused(nextPaused);
try {
const res = await fetch('/api/player/pause', {
method: 'POST',
@@ -127,27 +142,28 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
isPaused: String(isPaused),
isPaused: nextPaused, // boolean 그대로 전송
})
});
const data = await res.json();
if (res.ok && data.success) {
if (data.isPaused?.trim().toLocaleLowerCase() === "true") {
setIsPaused(true);
} else {
setIsPaused(false);
}
// 봇이 실제로 적용된 paused 상태를 돌려줌. 없으면 낙관적 값 유지.
if (typeof data.paused === "boolean") setIsPaused(data.paused);
} else {
// 실패 시 롤백
setIsPaused(!nextPaused);
}
} catch (error) {
console.error("일시정지 에러:", error);
setIsPaused(!isPaused); // 실패 시 롤백
setIsPaused(!nextPaused); // 실패 시 롤백
}
};
// 다음 곡 스킵 (player_skip)
const handleSkip = async () => {
if (!selectedServer || !track) return;
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
if (!userId) return;
try {
await fetch('/api/player/skip', {
method: 'POST',
@@ -167,7 +183,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
// 🌟 [수정됨] 마우스 이벤트와 터치 이벤트를 모두 허용하도록 타입 변경
const handleSeekEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
if (!selectedServer || !track) return;
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
if (!userId) return;
isDragging.current = false;
// 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다.
@@ -198,7 +215,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const handleVolumeEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
setIsVolumeDragging(false);
if (!selectedServer) return;
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
if (!userId) return;
const finalVolume = Number(e.currentTarget.value);
setVolume(finalVolume); // UI 즉시 반영

View File

@@ -3,9 +3,10 @@ import { useState, useRef, useEffect, useCallback } from "react";
import { Trash2, GripVertical, Music } from "lucide-react";
import { useSession } from "next-auth/react";
import { useToast } from "@/components/ToastProvider";
import type { DiscordServer, Track } from "@/types/music";
interface QueueSidebarProps {
selectedServer: any;
selectedServer: DiscordServer | null;
}
export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
@@ -14,8 +15,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
// 👇 [추가] 토스트 사용 준비 완료!
const { showToast } = useToast();
const [queue, setQueue] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [queue, setQueue] = useState<Track[]>([]);
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
@@ -26,10 +26,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
const fetchQueue = useCallback(async () => {
if (!selectedServer) return;
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
if (!userId) return;
setIsLoading(true);
try {
const res = await fetch('/api/queue/list', {
method: 'POST',
@@ -41,29 +40,33 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
});
const data = await res.json();
if (res.ok && data.success && Array.isArray(data.queue)) {
setQueue(data.queue);
setQueue(data.queue as Track[]);
} else {
setQueue([]);
}
} catch (error) {
console.error("큐 불러오기 실패:", error);
setQueue([]);
} finally {
setIsLoading(false);
}
}, [selectedServer, session]);
useEffect(() => {
if (status === "loading" || !selectedServer) return;
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchQueue();
const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "queue_update") {
fetchQueue();
try {
const data = JSON.parse(event.data);
if (data?.type === "queue_update") {
fetchQueue();
}
} catch (err) {
console.warn("SSE JSON 파싱 실패:", err);
}
};
@@ -106,7 +109,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
}
// --- 여기서부터는 진짜 순서가 바뀌었을 때만 실행됩니다 ---
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
// 3. 화면 즉시 업데이트
const newQueue = [...queue];
@@ -139,7 +142,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
const handleDelete = async (indexToRemove: number) => {
if (!selectedServer) return;
const userId = (session?.user as any)?.id;
const userId = session?.user?.id;
const newQueue = queue.filter((_, index) => index !== indexToRemove);
setQueue(newQueue);