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

@@ -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 즉시 반영