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

@@ -142,7 +142,8 @@ class RedisClientClass {
const resultKey = `queue:remove:${data.requestId}`; const resultKey = `queue:remove:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); 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.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.index) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index를 찾을수 없습니다." })); // 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); const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return; if (!context.ok) return;
const numIndex = Number(data.index); const numIndex = Number(data.index);
@@ -159,7 +160,8 @@ class RedisClientClass {
const resultKey = `player:paused:${data.requestId}`; const resultKey = `player:paused:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); 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.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.isPaused) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "paused를 찾을수 없습니다." })); // 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); const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return; if (!context.ok) return;
await context.player.setPause(); await context.player.setPause();
@@ -178,7 +180,8 @@ class RedisClientClass {
const resultKey = `player:seek:${data.requestId}`; const resultKey = `player:seek:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); 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.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.seek) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek를 찾을수 없습니다." })); // 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); const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return; 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.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
@@ -194,7 +197,8 @@ class RedisClientClass {
const resultKey = `player:volume:${data.requestId}`; const resultKey = `player:volume:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); 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.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.volume) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume을 찾을수 없습니다." })); // 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); const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return; 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.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));

View File

@@ -1,12 +1,38 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ // 보안 헤더
allowedDevOrigins: [ async headers() {
"192.168.10.13", return [
"localhost", {
"music.tkrmagid.kr" source: "/(.*)",
] headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
{
key: "Content-Security-Policy",
// 디스코드 CDN(이미지)과 자기 자신만 신뢰
value: [
"default-src 'self'",
"img-src 'self' data: https://cdn.discordapp.com https://i.scdn.co https://i.ytimg.com https://lh3.googleusercontent.com",
"script-src 'self' 'unsafe-inline'" + (process.env.NODE_ENV === "production" ? "" : " 'unsafe-eval'"),
"style-src 'self' 'unsafe-inline'",
"connect-src 'self' https://discord.com",
"font-src 'self' data:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self' https://discord.com",
].join("; "),
},
],
},
];
},
}; };
export default nextConfig; export default nextConfig;

204
page/package-lock.json generated
View File

@@ -11,7 +11,7 @@
"colors": "^1.4.0", "colors": "^1.4.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"next": "16.2.2", "next": "^16.2.4",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4"
@@ -22,7 +22,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.2", "eslint-config-next": "^16.2.4",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }
@@ -611,9 +611,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -630,9 +627,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -649,9 +643,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -668,9 +659,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -687,9 +675,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -706,9 +691,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -725,9 +707,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -744,9 +723,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -763,9 +739,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -788,9 +761,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -813,9 +783,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -838,9 +805,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -863,9 +827,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -888,9 +849,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -913,9 +871,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -938,9 +893,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1102,15 +1054,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.2.2", "version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
"integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==", "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "16.2.2", "version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz",
"integrity": "sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==", "integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1118,9 +1070,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.2.2", "version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
"integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==", "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1134,9 +1086,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.2.2", "version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz",
"integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==", "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1150,15 +1102,12 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.2", "version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz",
"integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==", "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1169,15 +1118,12 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.2", "version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz",
"integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==", "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1188,15 +1134,12 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.2", "version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz",
"integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==", "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1207,15 +1150,12 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.2", "version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz",
"integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==", "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1226,9 +1166,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.2", "version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz",
"integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==", "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1242,9 +1182,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.2", "version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
"integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==", "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1463,9 +1403,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1483,9 +1420,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1503,9 +1437,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1523,9 +1454,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2076,9 +2004,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2093,9 +2018,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2110,9 +2032,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2127,9 +2046,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2144,9 +2060,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2161,9 +2074,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2178,9 +2088,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2195,9 +2102,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3270,13 +3174,13 @@
} }
}, },
"node_modules/eslint-config-next": { "node_modules/eslint-config-next": {
"version": "16.2.2", "version": "16.2.4",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.2.tgz", "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz",
"integrity": "sha512-6VlvEhwoug2JpVgjZDhyXrJXUEuPY++TddzIpTaIRvlvlXXFgvQUtm3+Zr84IjFm0lXtJt73w19JA08tOaZVwg==", "integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/eslint-plugin-next": "16.2.2", "@next/eslint-plugin-next": "16.2.4",
"eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2", "eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
@@ -4873,9 +4777,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4897,9 +4798,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4921,9 +4819,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4945,9 +4840,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5185,12 +5077,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.2.2", "version": "16.2.4",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
"integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==", "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.2.2", "@next/env": "16.2.4",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19", "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
@@ -5204,14 +5096,14 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.2", "@next/swc-darwin-arm64": "16.2.4",
"@next/swc-darwin-x64": "16.2.2", "@next/swc-darwin-x64": "16.2.4",
"@next/swc-linux-arm64-gnu": "16.2.2", "@next/swc-linux-arm64-gnu": "16.2.4",
"@next/swc-linux-arm64-musl": "16.2.2", "@next/swc-linux-arm64-musl": "16.2.4",
"@next/swc-linux-x64-gnu": "16.2.2", "@next/swc-linux-x64-gnu": "16.2.4",
"@next/swc-linux-x64-musl": "16.2.2", "@next/swc-linux-x64-musl": "16.2.4",
"@next/swc-win32-arm64-msvc": "16.2.2", "@next/swc-win32-arm64-msvc": "16.2.4",
"@next/swc-win32-x64-msvc": "16.2.2", "@next/swc-win32-x64-msvc": "16.2.4",
"sharp": "^0.34.5" "sharp": "^0.34.5"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -12,7 +12,7 @@
"colors": "^1.4.0", "colors": "^1.4.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"next": "16.2.2", "next": "^16.2.4",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4"
@@ -23,7 +23,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.2", "eslint-config-next": "^16.2.4",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }

View File

@@ -1,11 +1,30 @@
import NextAuth, { NextAuthOptions } from "next-auth"; import NextAuth, { NextAuthOptions } from "next-auth";
import DiscordProvider from "next-auth/providers/discord"; import DiscordProvider from "next-auth/providers/discord";
// 환경변수 부팅 시점 검증 — 누락 시 즉시 실패
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID?.trim();
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET?.trim();
const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET?.trim();
if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET) {
throw new Error("[NextAuth] DISCORD_CLIENT_ID/DISCORD_CLIENT_SECRET 환경변수가 설정되지 않았습니다.");
}
if (!NEXTAUTH_SECRET) {
throw new Error("[NextAuth] NEXTAUTH_SECRET 환경변수가 설정되지 않았습니다.");
}
interface DiscordProfile {
id?: string;
username?: string;
email?: string;
}
export const authOptions: NextAuthOptions = { export const authOptions: NextAuthOptions = {
secret: NEXTAUTH_SECRET,
providers: [ providers: [
DiscordProvider({ DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID as string, clientId: DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET as string, clientSecret: DISCORD_CLIENT_SECRET,
// 🌟 핵심: 로그인할 때 유저의 기본 정보(identify)와 서버 목록(guilds) 권한을 같이 가져옵니다! // 🌟 핵심: 로그인할 때 유저의 기본 정보(identify)와 서버 목록(guilds) 권한을 같이 가져옵니다!
authorization: { params: { scope: "identify email guilds" } }, authorization: { params: { scope: "identify email guilds" } },
}), }),
@@ -16,15 +35,16 @@ export const authOptions: NextAuthOptions = {
callbacks: { callbacks: {
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직 // 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
async jwt({ token, account, profile }) { async jwt({ token, account, profile }) {
if (account && (profile as any)?.id) { const discordProfile = profile as DiscordProfile | undefined;
token.id = (profile as any).id; if (account && discordProfile?.id) {
token.id = discordProfile.id;
token.accessToken = account.access_token; token.accessToken = account.access_token;
} }
return token; return token;
}, },
async session({ session, token }: any) { async session({ session, token }) {
session.user.id = token.id; if (token.id) session.user.id = token.id;
session.accessToken = token.accessToken; if (token.accessToken) session.accessToken = token.accessToken;
return session; return session;
}, },
}, },

View File

@@ -1,54 +1,10 @@
// src/app/api/queue/events/route.ts // src/app/api/player/events/route.ts
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트 import { botEventStream } from "@/lib/sse";
// 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다. // 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다.
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
// 프론트엔드에서 보낸 serverId 가져오기 return botEventStream(req, { botEventName: "player_update" });
const serverId = req.nextUrl.searchParams.get("serverId");
if (!serverId) {
return new Response("Missing serverId", { status: 400 });
}
// SSE(Server-Sent Events) 스트림 생성
const stream = new ReadableStream({
async start(controller) {
// 🚨 중요: 구독(Subscribe) 전용으로 쓸 독립적인 Redis 연결을 하나 복제합니다.
const subscriber = Redis.duplicate();
// 'bot-site' 채널 구독
await subscriber.subscribe("bot-site");
// 메세지가 들어올 때마다 실행
subscriber.on("message", (channel, message) => {
if (channel !== "bot-site") return;
const data = JSON.parse(message);
if (data.guildId !== serverId) return;
// 알림이 울린 서버와 현재 유저가 보고 있는 서버가 일치할 때만!
if (data.event === "player_update") {
// 프론트엔드로 "새로고침해!" 라는 데이터를 전송
controller.enqueue(`data: ${JSON.stringify({ type: "player_update" })}\n\n`);
}
});
// 클라이언트(웹사이트)가 브라우저를 닫거나 다른 페이지로 가면 연결 종료 및 정리
req.signal.addEventListener("abort", () => {
subscriber.unsubscribe("bot-site");
subscriber.quit();
controller.close();
});
}
});
// 스트림 응답 헤더 설정 (연결을 끊지 않고 계속 유지)
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
} }

View File

@@ -1,42 +1,34 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis"; import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface NowBody {
serverId?: unknown;
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const sessionResult = await requireSession();
const { serverId, userId } = body; if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); const bodyResult = await readJsonBody<NowBody>(request);
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); if (!bodyResult.ok) return bodyResult.response;
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
const resultKey = `player:now:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'player_now' 명령 전송 const { status, body } = await botRpc({
await Redis.publish("site-bot", JSON.stringify({ channel: "player:now",
action: "player_now", payload: {
requestId: requestId, action: "player_now",
serverId: serverId, serverId: serverIdResult.value,
userId: userId, userId,
})); },
});
// 3. 봇의 대답 기다리기 (최대 약 3초 대기) return NextResponse.json(body, { status });
for (let i = 0; i < 15; i++) {
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
const botReply = await Redis.get(resultKey);
if (botReply) {
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
await Redis.del(resultKey);
const replyData = JSON.parse(botReply);
// replyData.success 가 false면 에러 상태코드(400)로 보냄
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
} catch (error) { } catch (error) {
console.error("Play API Error:", error); Logger.error(`player/now API error: ${error instanceof Error ? error.message : String(error)}`);
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); return errorResponse("서버 오류가 발생했습니다.", 500);
} }
} }

View File

@@ -1,43 +1,46 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis"; import { Logger } from "@/lib/Logger";
import {
botRpc,
errorResponse,
readJsonBody,
requireBoolean,
requireSession,
requireString,
} from "@/lib/api";
interface PauseBody {
serverId?: unknown;
isPaused?: unknown;
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const sessionResult = await requireSession();
const { serverId, userId, isPaused } = body; if (!sessionResult.ok) return sessionResult.response;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); const userId = sessionResult.session.user.id;
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
if (!isPaused) return NextResponse.json({ error: "isPaused 정보가 필요합니다." }, { status: 400 });
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; const bodyResult = await readJsonBody<PauseBody>(request);
const resultKey = `player:paused:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 if (!bodyResult.ok) return bodyResult.response;
// 봇에게 'player_pause' 명령 전송 const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
await Redis.publish("site-bot", JSON.stringify({ if (!serverIdResult.ok) return serverIdResult.response;
action: "player_paused",
requestId: requestId,
serverId: serverId,
userId: userId,
isPaused: isPaused,
}));
// 3. 봇의 대답 기다리기 (최대 약 3초 대기) const isPausedResult = requireBoolean(bodyResult.data.isPaused, "isPaused");
for (let i = 0; i < 15; i++) { if (!isPausedResult.ok) return isPausedResult.response;
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
const botReply = await Redis.get(resultKey);
if (botReply) {
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
await Redis.del(resultKey);
const replyData = JSON.parse(botReply);
// replyData.success 가 false면 에러 상태코드(400)로 보냄
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
}
}
// 3초가 지나도 봇이 묵묵부답일 때 const { status, body } = await botRpc({
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 }); channel: "player:paused",
payload: {
action: "player_paused",
serverId: serverIdResult.value,
userId,
isPaused: isPausedResult.value,
},
});
return NextResponse.json(body, { status });
} catch (error) { } catch (error) {
console.error("Play API Error:", error); Logger.error(`player/pause API error: ${error instanceof Error ? error.message : String(error)}`);
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); return errorResponse("서버 오류가 발생했습니다.", 500);
} }
} }

View File

@@ -1,44 +1,39 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis"; import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface PlayBody {
serverId?: unknown;
track?: unknown;
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const sessionResult = await requireSession();
const { serverId, userId, track } = body; if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); const bodyResult = await readJsonBody<PlayBody>(request);
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); if (!bodyResult.ok) return bodyResult.response;
if (!track) return NextResponse.json({ error: "track 정보가 필요합니다." }, { status: 400 });
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
const resultKey = `player:play:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'player_play' 명령 전송 const track = bodyResult.data.track;
await Redis.publish("site-bot", JSON.stringify({ if (!track || typeof track !== "object") return errorResponse("track 정보가 필요합니다.");
action: "player_play",
requestId: requestId,
serverId: serverId,
userId: userId,
track: track,
}));
// 3. 봇의 대답 기다리기 (최대 약 3초 대기) const { status, body } = await botRpc({
for (let i = 0; i < 15; i++) { channel: "player:play",
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기 payload: {
const botReply = await Redis.get(resultKey); action: "player_play",
if (botReply) { serverId: serverIdResult.value,
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달 userId,
await Redis.del(resultKey); track,
const replyData = JSON.parse(botReply); },
// replyData.success 가 false면 에러 상태코드(400)로 보냄 });
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 }); return NextResponse.json(body, { status });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
} catch (error) { } catch (error) {
console.error("Play API Error:", error); Logger.error(`player/play API error: ${error instanceof Error ? error.message : String(error)}`);
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); return errorResponse("서버 오류가 발생했습니다.", 500);
} }
} }

View File

@@ -1,44 +1,39 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis"; import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface PlaylistBody {
serverId?: unknown;
playlistUrl?: unknown;
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const sessionResult = await requireSession();
const { serverId, userId, playlistUrl } = body; if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); const bodyResult = await readJsonBody<PlaylistBody>(request);
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); if (!bodyResult.ok) return bodyResult.response;
if (!playlistUrl) return NextResponse.json({ error: "playlistUrl 정보가 필요합니다." }, { status: 400 });
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
const resultKey = `player:playlist:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'player_playlist' 명령 전송 const urlResult = requireString(bodyResult.data.playlistUrl, "playlistUrl");
await Redis.publish("site-bot", JSON.stringify({ if (!urlResult.ok) return urlResult.response;
action: "player_playlist",
requestId: requestId,
serverId: serverId,
userId: userId,
playlistUrl: playlistUrl,
}));
// 3. 봇의 대답 기다리기 (최대 약 3초 대기) const { status, body } = await botRpc({
for (let i = 0; i < 15; i++) { channel: "player:playlist",
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기 payload: {
const botReply = await Redis.get(resultKey); action: "player_playlist",
if (botReply) { serverId: serverIdResult.value,
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달 userId,
await Redis.del(resultKey); playlistUrl: urlResult.value,
const replyData = JSON.parse(botReply); },
// replyData.success 가 false면 에러 상태코드(400)로 보냄 });
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 }); return NextResponse.json(body, { status });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
} catch (error) { } catch (error) {
console.error("Queue Adds API Error:", error); Logger.error(`player/playlist API error: ${error instanceof Error ? error.message : String(error)}`);
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); return errorResponse("서버 오류가 발생했습니다.", 500);
} }
} }

View File

@@ -1,44 +1,47 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis"; import { Logger } from "@/lib/Logger";
import {
botRpc,
errorResponse,
readJsonBody,
requireNumber,
requireSession,
requireString,
} from "@/lib/api";
interface SeekBody {
serverId?: unknown;
seek?: unknown;
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const sessionResult = await requireSession();
const { serverId, userId, seek } = body; if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); const bodyResult = await readJsonBody<SeekBody>(request);
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); if (!bodyResult.ok) return bodyResult.response;
if (!seek) return NextResponse.json({ error: "seek 정보가 필요합니다." }, { status: 400 });
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
const resultKey = `player:seek:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'player_seek' 명령 전송 // seek 는 0(처음으로 되감기) 도 정상 입력. requireNumber 는 0 허용.
await Redis.publish("site-bot", JSON.stringify({ const seekResult = requireNumber(bodyResult.data.seek, "seek", { min: 0, integer: true });
action: "player_seek", if (!seekResult.ok) return seekResult.response;
requestId: requestId,
serverId: serverId,
userId: userId,
seek: seek,
}));
// 3. 봇의 대답 기다리기 (최대 약 3초 대기) const { status, body } = await botRpc({
for (let i = 0; i < 15; i++) { channel: "player:seek",
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기 payload: {
const botReply = await Redis.get(resultKey); action: "player_seek",
if (botReply) { serverId: serverIdResult.value,
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달 userId,
await Redis.del(resultKey); seek: seekResult.value,
const replyData = JSON.parse(botReply); },
// replyData.success 가 false면 에러 상태코드(400)로 보냄 });
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 }); return NextResponse.json(body, { status });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
} catch (error) { } catch (error) {
console.error("Play API Error:", error); Logger.error(`player/seek API error: ${error instanceof Error ? error.message : String(error)}`);
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); return errorResponse("서버 오류가 발생했습니다.", 500);
} }
} }

View File

@@ -1,42 +1,34 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis"; import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface SkipBody {
serverId?: unknown;
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const sessionResult = await requireSession();
const { serverId, userId } = body; if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); const bodyResult = await readJsonBody<SkipBody>(request);
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); if (!bodyResult.ok) return bodyResult.response;
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
const resultKey = `player:skip:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'player_skip' 명령 전송 const { status, body } = await botRpc({
await Redis.publish("site-bot", JSON.stringify({ channel: "player:skip",
action: "player_skip", payload: {
requestId: requestId, action: "player_skip",
serverId: serverId, serverId: serverIdResult.value,
userId: userId, userId,
})); },
});
// 3. 봇의 대답 기다리기 (최대 약 3초 대기) return NextResponse.json(body, { status });
for (let i = 0; i < 15; i++) {
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
const botReply = await Redis.get(resultKey);
if (botReply) {
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
await Redis.del(resultKey);
const replyData = JSON.parse(botReply);
// replyData.success 가 false면 에러 상태코드(400)로 보냄
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
} catch (error) { } catch (error) {
console.error("Play API Error:", error); Logger.error(`player/skip API error: ${error instanceof Error ? error.message : String(error)}`);
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); return errorResponse("서버 오류가 발생했습니다.", 500);
} }
} }

View File

@@ -1,44 +1,50 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis"; import { Logger } from "@/lib/Logger";
import {
botRpc,
errorResponse,
readJsonBody,
requireNumber,
requireSession,
requireString,
} from "@/lib/api";
interface VolumeBody {
serverId?: unknown;
volume?: unknown;
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const sessionResult = await requireSession();
const { serverId, userId, volume } = body; if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); const bodyResult = await readJsonBody<VolumeBody>(request);
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); if (!bodyResult.ok) return bodyResult.response;
if (!volume) return NextResponse.json({ error: "volume 정보가 필요합니다." }, { status: 400 });
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
const resultKey = `player:volume:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'player_volume' 명령 전송 const volumeResult = requireNumber(bodyResult.data.volume, "volume", {
await Redis.publish("site-bot", JSON.stringify({ min: 0,
action: "player_volume", max: 100,
requestId: requestId, integer: true,
serverId: serverId, });
userId: userId, if (!volumeResult.ok) return volumeResult.response;
volume: volume,
}));
// 3. 봇의 대답 기다리기 (최대 약 3초 대기) const { status, body } = await botRpc({
for (let i = 0; i < 15; i++) { channel: "player:volume",
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기 payload: {
const botReply = await Redis.get(resultKey); action: "player_volume",
if (botReply) { serverId: serverIdResult.value,
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달 userId,
await Redis.del(resultKey); volume: volumeResult.value,
const replyData = JSON.parse(botReply); },
// replyData.success 가 false면 에러 상태코드(400)로 보냄 });
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 }); return NextResponse.json(body, { status });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
} catch (error) { } catch (error) {
console.error("Play API Error:", error); Logger.error(`player/volume API error: ${error instanceof Error ? error.message : String(error)}`);
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); return errorResponse("서버 오류가 발생했습니다.", 500);
} }
} }

View File

@@ -1,54 +1,10 @@
// src/app/api/queue/events/route.ts // src/app/api/queue/events/route.ts
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트 import { botEventStream } from "@/lib/sse";
// 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다. // 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다.
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
// 프론트엔드에서 보낸 serverId 가져오기 return botEventStream(req, { botEventName: "queue_update" });
const serverId = req.nextUrl.searchParams.get("serverId");
if (!serverId) {
return new Response("Missing serverId", { status: 400 });
}
// SSE(Server-Sent Events) 스트림 생성
const stream = new ReadableStream({
async start(controller) {
// 🚨 중요: 구독(Subscribe) 전용으로 쓸 독립적인 Redis 연결을 하나 복제합니다.
const subscriber = Redis.duplicate();
// 'bot-site' 채널 구독
await subscriber.subscribe("bot-site");
// 메세지가 들어올 때마다 실행
subscriber.on("message", (channel, message) => {
if (channel !== "bot-site") return;
const data = JSON.parse(message);
if (data.guildId !== serverId) return;
// 알림이 울린 서버와 현재 유저가 보고 있는 서버가 일치할 때만!
if (data.event === "queue_update") {
// 프론트엔드로 "새로고침해!" 라는 데이터를 전송
controller.enqueue(`data: ${JSON.stringify({ type: "queue_update" })}\n\n`);
}
});
// 클라이언트(웹사이트)가 브라우저를 닫거나 다른 페이지로 가면 연결 종료 및 정리
req.signal.addEventListener("abort", () => {
subscriber.unsubscribe("bot-site");
subscriber.quit();
controller.close();
});
}
});
// 스트림 응답 헤더 설정 (연결을 끊지 않고 계속 유지)
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
} }

View File

@@ -1,44 +1,34 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis"; import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface QueueListBody {
serverId?: unknown;
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const sessionResult = await requireSession();
const { serverId, userId } = body; if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); const bodyResult = await readJsonBody<QueueListBody>(request);
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); if (!bodyResult.ok) return bodyResult.response;
// 1. 고유한 요청 ID(진동벨) 생성 const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; if (!serverIdResult.ok) return serverIdResult.response;
const resultKey = `queue:list:${requestId}`;
// 2. 봇에게 'queue_list' 명령 발송
await Redis.publish("site-bot", JSON.stringify({
action: "queue_list",
serverId: serverId,
userId: userId,
requestId: requestId, // 🌟 봇이 대답을 남길 키
}));
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
for (let i = 0; i < 15; i++) {
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
const botReply = await Redis.get(resultKey);
if (botReply) {
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
await Redis.del(resultKey);
const replyData = JSON.parse(botReply);
// replyData.success 가 false면 에러 상태코드(400)로 보냄
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
}
}
// 3초가 지나도 봇이 묵묵부답일 때
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
const { status, body } = await botRpc({
channel: "queue:list",
payload: {
action: "queue_list",
serverId: serverIdResult.value,
userId,
},
});
return NextResponse.json(body, { status });
} catch (error) { } catch (error) {
console.error("Queue List API Error:", error); Logger.error(`queue/list API error: ${error instanceof Error ? error.message : String(error)}`);
return NextResponse.json({ success: false, message: "서버 오류가 발생했습니다." }, { status: 500 }); return errorResponse("서버 오류가 발생했습니다.", 500);
} }
} }

View File

@@ -1,45 +1,48 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis"; import { Logger } from "@/lib/Logger";
import {
botRpc,
errorResponse,
readJsonBody,
requireNumber,
requireSession,
requireString,
} from "@/lib/api";
interface QueueRemoveBody {
serverId?: unknown;
index?: unknown;
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const sessionResult = await requireSession();
const { serverId, index, userId } = body; if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); const bodyResult = await readJsonBody<QueueRemoveBody>(request);
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); if (!bodyResult.ok) return bodyResult.response;
if (!index) return NextResponse.json({ error: "index 정보가 필요합니다." }, { status: 400 });
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
const resultKey = `queue:remove:${requestId}`; if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'remove_queue' 명령 발송 (몇 번째 인덱스를 지워라) // index 0 도 정상값
await Redis.publish("site-bot", JSON.stringify({ const indexResult = requireNumber(bodyResult.data.index, "index", { min: 0, integer: true });
action: "queue_remove", if (!indexResult.ok) return indexResult.response;
serverId: serverId,
requestId: requestId,
userId: userId,
index: index,
}));
// 4. 결과가 올라올 때까지 기다리기 (Polling) const { status, body } = await botRpc({
// 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다. channel: "queue:remove",
for (let i = 0; i < 10; i++) { payload: {
// 0.5초 대기 action: "queue_remove",
await new Promise(resolve => setTimeout(resolve, 500)); serverId: serverIdResult.value,
userId,
// Redis 게시판 확인 index: indexResult.value,
const resultData = await Redis.get(resultKey); },
timeoutMs: 5000,
if (resultData) { });
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료 return NextResponse.json(body, { status });
return NextResponse.json(JSON.parse(resultData));
}
}
// 5초가 지나도 응답이 없으면 타임아웃
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
} catch (error) { } catch (error) {
return NextResponse.json({ error: "서버 오류" }, { status: 500 }); Logger.error(`queue/remove API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
} }
} }

View File

@@ -1,46 +1,40 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis"; import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface QueueSetBody {
serverId?: unknown;
newQueue?: unknown;
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const sessionResult = await requireSession();
const { serverId, newQueue, userId } = body; if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); const bodyResult = await readJsonBody<QueueSetBody>(request);
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); if (!bodyResult.ok) return bodyResult.response;
if (newQueue === undefined || newQueue === null) return NextResponse.json({ error: "newQueue 정보가 필요합니다." }, { status: 400 });
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
const resultKey = `queue:set:${requestId}`; if (!serverIdResult.ok) return serverIdResult.response;
// 봇에게 'queue_set' 명령 발송 (전체 대기열을 통째로 덮어써라!) const newQueue = bodyResult.data.newQueue;
await Redis.publish("site-bot", JSON.stringify({ if (!Array.isArray(newQueue)) return errorResponse("newQueue 정보가 필요합니다.");
action: "queue_set",
serverId: serverId,
requestId: requestId,
userId: userId,
newQueue: newQueue,
}));
// 4. 결과가 올라올 때까지 기다리기 (Polling) const { status, body } = await botRpc({
// 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다. channel: "queue:set",
for (let i = 0; i < 10; i++) { payload: {
// 0.5초 대기 action: "queue_set",
await new Promise(resolve => setTimeout(resolve, 500)); serverId: serverIdResult.value,
userId,
// Redis 게시판 확인 newQueue,
const resultData = await Redis.get(resultKey); },
timeoutMs: 5000,
if (resultData) { });
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료 return NextResponse.json(body, { status });
return NextResponse.json(JSON.parse(resultData));
}
}
// 5초가 지나도 응답이 없으면 타임아웃
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
} catch (error) { } catch (error) {
console.error("Queue Reorder API Error:", error); Logger.error(`queue/set API error: ${error instanceof Error ? error.message : String(error)}`);
return NextResponse.json({ error: "서버 오류" }, { status: 500 }); return errorResponse("서버 오류가 발생했습니다.", 500);
} }
} }

View File

@@ -1,42 +1,30 @@
// src/app/api/search/route.ts // src/app/api/search/route.ts
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis"; import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, requireSession } from "@/lib/api";
export async function GET(request: Request) { export async function GET(request: Request) {
// 1. 검색어(query) 가져오기 try {
const { searchParams } = new URL(request.url); const sessionResult = await requireSession();
const query = searchParams.get("q"); if (!sessionResult.ok) return sessionResult.response;
if (!query) { const { searchParams } = new URL(request.url);
return NextResponse.json({ error: "검색어가 없습니다." }, { status: 400 }); const query = searchParams.get("q")?.trim();
if (!query) return errorResponse("검색어가 없습니다.", 400);
const { status, body } = await botRpc({
channel: "search",
payload: {
action: "search",
query,
},
timeoutMs: 10000,
pollIntervalMs: 250,
});
return NextResponse.json(body, { status });
} catch (error) {
Logger.error(`search API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
} }
// 2. 고유한 요청 ID 생성 (예: 1690001234567-abc)
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
const resultKey = `search:${requestId}`;
// 3. 봇에게 'site-bot' 채널로 검색 명령 발송 (Publish)
await Redis.publish("site-bot", JSON.stringify({
action: "search",
query: query,
requestId: requestId,
}));
// 4. 결과가 올라올 때까지 기다리기 (Polling)
// 최대 10번(약 10초) 동안 1.0초 간격으로 확인합니다.
for (let i=0; i<10; i++) {
// 1.0초 대기
await new Promise(resolve => setTimeout(resolve, 1000));
// Redis 게시판 확인
const resultData = await Redis.get(resultKey);
if (resultData) {
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
return NextResponse.json(JSON.parse(resultData));
}
}
// 5초가 지나도 응답이 없으면 타임아웃
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
} }

View File

@@ -1,34 +1,58 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { Redis } from "@/lib/Redis"; import { Redis } from "@/lib/Redis";
import { authOptions } from "../auth/[...nextauth]/route"; import { Logger } from "@/lib/Logger";
import { requireSession } from "@/lib/api";
interface DiscordGuild {
id: string;
name: string;
icon: string | null;
owner: boolean;
permissions: string;
}
export async function GET() { export async function GET() {
const session = await getServerSession(authOptions) as any; const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
if (!session || !session.accessToken) { const accessToken = sessionResult.session.accessToken;
return NextResponse.json({ error: "인증되지 않았습니다." }, { status: 401 }); if (!accessToken) {
return NextResponse.json({ success: false, error: "Discord 액세스 토큰이 없습니다." }, { status: 401 });
} }
try { try {
// 1. 디스코드 API에서 유저가 속한 서버 목록 가져오기 // 1. 디스코드 API에서 유저가 속한 서버 목록 가져오기
const userGuildsRes = await fetch("https://discord.com/api/users/@me/guilds", { const userGuildsRes = await fetch("https://discord.com/api/users/@me/guilds", {
headers: { Authorization: `Bearer ${session.accessToken}` }, headers: { Authorization: `Bearer ${accessToken}` },
}); });
const userGuilds = await userGuildsRes.json() ?? []; if (!userGuildsRes.ok) {
Logger.warn(`Discord guilds API ${userGuildsRes.status} ${userGuildsRes.statusText}`);
return NextResponse.json(
{ success: false, error: "Discord 서버 목록을 가져오지 못했습니다." },
{ status: 502 },
);
}
const userGuildsRaw: unknown = await userGuildsRes.json();
const userGuilds: DiscordGuild[] = Array.isArray(userGuildsRaw) ? (userGuildsRaw as DiscordGuild[]) : [];
// 2. Redis에서 봇이 속한 서버 목록(화이트리스트) 가져오기 // 2. Redis에서 봇이 속한 서버 목록(화이트리스트) 가져오기
const botGuildsData = await Redis.get("bot-guilds"); const botGuildsData = await Redis.get("bot-guilds");
const botGuildIds: string[] = botGuildsData ? JSON.parse(botGuildsData) : []; let botGuildIds: string[] = [];
if (botGuildsData) {
try {
const parsed = JSON.parse(botGuildsData);
if (Array.isArray(parsed)) botGuildIds = parsed.filter((v): v is string => typeof v === "string");
} catch {
Logger.warn("Redis bot-guilds JSON 파싱 실패");
}
}
// 3. 🌟 두 목록을 비교해서 봇이 있는 서버만 필터링! // 3. 🌟 두 목록을 비교해서 봇이 있는 서버만 필터링!
const filteredGuilds = userGuilds.filter((guild: any) => const filteredGuilds = userGuilds.filter((guild) => botGuildIds.includes(guild.id));
botGuildIds.includes(guild.id)
);
return NextResponse.json(filteredGuilds); return NextResponse.json(filteredGuilds);
} catch (error) { } catch (error) {
console.error("서버 필터링 에러:", error); Logger.error(`서버 필터링 에러: ${error instanceof Error ? error.message : String(error)}`);
return NextResponse.json({ error: "서버 목록을 가져오지 못했습니다." }, { status: 500 }); return NextResponse.json({ success: false, error: "서버 목록을 가져오지 못했습니다." }, { status: 500 });
} }
} }

View File

@@ -6,13 +6,14 @@ import LeftSidebar from "@/components/layout/LeftSidebar";
import MainContent from "@/components/player/MainContent"; import MainContent from "@/components/player/MainContent";
import QueueSidebar from "@/components/player/QueueSidebar"; import QueueSidebar from "@/components/player/QueueSidebar";
import PlayerBar from "@/components/player/PlayerBar"; import PlayerBar from "@/components/player/PlayerBar";
import type { DiscordServer } from "@/types/music";
// 화면 모드 타입 정의 // 화면 모드 타입 정의
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT"; export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
export default function MusicPlayerLayout() { export default function MusicPlayerLayout() {
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST"); const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
const [selectedServer, setSelectedServer] = useState<any>(null); const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
// 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀 // 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀
@@ -33,7 +34,7 @@ export default function MusicPlayerLayout() {
}; };
// 서버 선택 시 // 서버 선택 시
const handleSelectServer = (server: any) => { const handleSelectServer = (server: DiscordServer) => {
setSelectedServer(server); setSelectedServer(server);
setViewMode("SERVER_DETAIL"); setViewMode("SERVER_DETAIL");
}; };

View File

@@ -1,25 +1,20 @@
"use client"; "use client";
import { ListMusic, Library } from "lucide-react"; import { ListMusic } from "lucide-react";
export default function LeftSidebar() { export default function LeftSidebar() {
return ( return (
<aside className="w-60 bg-black p-6 flex flex-col gap-6"> <aside className="w-60 bg-black p-6 flex flex-col gap-6">
<nav className="flex flex-col gap-4 text-neutral-400 font-medium"> <div className="flex items-center gap-2 text-neutral-400 font-medium">
<button className="flex items-center gap-3 hover:text-white transition-colors text-left"> <ListMusic size={20} />
<Library size={20} /> <span> </span>
</button> </div>
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
<ListMusic size={20} />
</button>
</nav>
<hr className="border-neutral-800" /> <hr className="border-neutral-800" />
<div className="flex flex-col gap-3 text-sm text-neutral-400 overflow-y-auto"> <p className="text-xs text-neutral-500 leading-relaxed">
<p className="hover:text-white cursor-pointer truncate"> </p> .
<p className="hover:text-white cursor-pointer truncate">2024 100</p> .
<p className="hover:text-white cursor-pointer truncate"> </p> </p>
</div>
</aside> </aside>
); );
} }

View File

@@ -2,11 +2,12 @@
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react"; import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react";
import { signIn, signOut, useSession } from "next-auth/react"; import { signIn, signOut, useSession } from "next-auth/react";
import { useState } from "react"; import { useState } from "react";
import type { DiscordServer } from "@/types/music";
interface TopNavProps { interface TopNavProps {
onSearch: (query: string) => void; onSearch: (query: string) => void;
onHome: () => void; onHome: () => void;
selectedServer: any; // 🌟 추가: 선택된 서버 정보 selectedServer: DiscordServer | null; // 🌟 추가: 선택된 서버 정보
} }
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) { export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,43 @@
import colors from "colors/safe"; import colors from "colors/safe";
// Asia/Seoul(UTC+9) 타임스탬프. ISO 포맷에서 안전하게 추출.
export const Timestamp = () => { export const Timestamp = () => {
const Now = new Date(); const now = new Date(Date.now() + 9 * 60 * 60 * 1000);
Now.setHours(Now.getHours() + 9); // YYYY-MM-DDTHH:mm:ss.sssZ 에서 YY-MM-DD HH:mm:ss
return Now.toISOString().replace('T', ' ').substring(0, 19).slice(2); const iso = now.toISOString();
} const date = iso.slice(2, 10); // YY-MM-DD
const time = iso.slice(11, 19); // HH:mm:ss
return `${date} ${time}`;
};
type logType = "log" | "info" | "warn" | "error" | "debug" | "ready" | "slash"; type LogType = "log" | "info" | "warn" | "error" | "debug" | "ready";
const log = (content: string, type: logType) => { const isProd = process.env.NODE_ENV === "production";
const write = (label: string, content: string, useStderr: boolean) => {
const timestamp = colors.white(`[${Timestamp()}]`); const timestamp = colors.white(`[${Timestamp()}]`);
const line = `${label} ${timestamp} ${content}`;
if (useStderr) console.error(line);
else console.log(line);
};
const log = (content: string, type: LogType) => {
switch (type) { switch (type) {
case "log": case "log":
return console.log(`${colors.gray("[LOG]")} ${timestamp} ${content}`); // 일반 디버그성 로그는 프로덕션에서 숨김
if (isProd) return;
return write(colors.gray("[LOG]"), content, false);
case "info": case "info":
return console.log(`${colors.cyan("[INFO]")} ${timestamp} ${content}`); return write(colors.cyan("[INFO]"), content, false);
case "warn": case "warn":
return console.log(`${colors.yellow("[WARN]")} ${timestamp} ${content}`); return write(colors.yellow("[WARN]"), content, true);
case "error": case "error":
return console.log(`${colors.red("[ERROR]")} ${timestamp} ${content}`); return write(colors.red("[ERROR]"), content, true);
case "debug": case "debug":
return console.log(`${colors.magenta("[DEBUG]")} ${timestamp} ${content}`); if (isProd) return;
return write(colors.magenta("[DEBUG]"), content, false);
case "ready": case "ready":
return console.log(`${colors.green("[READY]")} ${timestamp} ${content}`); return write(colors.green("[READY]"), content, false);
default: default:
throw new TypeError("Logger 타입이 올바르지 않습니다."); throw new TypeError("Logger 타입이 올바르지 않습니다.");
} }
@@ -34,5 +49,5 @@ export const Logger = {
error: (content: string) => log(content, "error"), error: (content: string) => log(content, "error"),
debug: (content: string) => log(content, "debug"), debug: (content: string) => log(content, "debug"),
info: (content: string) => log(content, "info"), info: (content: string) => log(content, "info"),
ready: (content: string) => log(content, "ready") ready: (content: string) => log(content, "ready"),
} };

View File

@@ -1,7 +1,16 @@
import { Redis as RedisClass } from "ioredis"; import { Redis as RedisClass } from "ioredis";
import { Logger } from "@/lib/Logger";
// .env.local 파일에서 설정한 IP를 가져옵니다. (기본값으로 Proxmox IP 세팅) // Redis 호스트는 환경변수에서 가져옵니다. 미설정 시 부팅 시점에 명확하게 에러를 던집니다.
const REDIS_HOST = process.env.REDIS_HOST || "192.168.10.7"; const REDIS_HOST = process.env.REDIS_HOST?.trim();
const REDIS_PORT = Number(process.env.REDIS_PORT?.trim() ?? "6379");
if (!REDIS_HOST) {
throw new Error("[Redis] REDIS_HOST 환경변수가 설정되지 않았습니다.");
}
if (!Number.isFinite(REDIS_PORT) || REDIS_PORT <= 0) {
throw new Error(`[Redis] REDIS_PORT 값이 올바르지 않습니다: ${process.env.REDIS_PORT}`);
}
// Next.js 개발 환경(HMR)에서 커넥션이 무한 증식하는 것을 막기 위한 글로벌 객체 선언 // Next.js 개발 환경(HMR)에서 커넥션이 무한 증식하는 것을 막기 위한 글로벌 객체 선언
const globalForRedis = global as unknown as { const globalForRedis = global as unknown as {
@@ -9,16 +18,24 @@ const globalForRedis = global as unknown as {
}; };
// 이미 연결된 객체가 있으면 그걸 쓰고, 없으면 새로 연결합니다. // 이미 연결된 객체가 있으면 그걸 쓰고, 없으면 새로 연결합니다.
export const Redis = globalForRedis.redisClient ?? new RedisClass({ host: REDIS_HOST, port: 6379 }); export const Redis = globalForRedis.redisClient ?? new RedisClass({
host: REDIS_HOST,
port: REDIS_PORT,
lazyConnect: false,
maxRetriesPerRequest: 3,
});
// 프로덕션(배포) 모드가 아닐 때만 글로벌 변수에 저장해 둡니다. // 프로덕션(배포) 모드가 아닐 때만 글로벌 변수에 저장해 둡니다.
if (process.env.NODE_ENV !== "production") globalForRedis.redisClient = Redis; if (process.env.NODE_ENV !== "production") globalForRedis.redisClient = Redis;
// 연결 성공 시 로그 한 번만 찍기 // 연결 성공 시 로그 한 번만 찍기
let connectLogged = false;
Redis.on("connect", () => { Redis.on("connect", () => {
console.log("🟢 [Next.js] Proxmox Redis(우체국) 연결 완료!"); if (connectLogged) return;
connectLogged = true;
Logger.ready(`Redis 연결 완료 (${REDIS_HOST}:${REDIS_PORT})`);
}); });
Redis.on("error", (err) => { Redis.on("error", (err) => {
console.error("❌ [Next.js] Redis 연결 에러:", err); Logger.error(`Redis 연결 에러: ${err instanceof Error ? err.message : String(err)}`);
}); });

163
page/src/lib/api.ts Normal file
View File

@@ -0,0 +1,163 @@
import { NextResponse } from "next/server";
import { getServerSession, type Session } from "next-auth";
import { randomUUID } from "node:crypto";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
// ========== 공용 응답 스키마 ==========
// 모든 API는 { success: boolean, ... } 형태로 응답한다.
// 실패시 { success: false, error: string } 보장.
export const errorResponse = (message: string, status = 400) =>
NextResponse.json({ success: false, error: message }, { status });
// ========== 세션 가드 ==========
// 세션이 없으면 401 NextResponse를 던지고, 있으면 세션을 돌려준다.
export async function requireSession(): Promise<
{ ok: true; session: Session } | { ok: false; response: NextResponse }
> {
const session = (await getServerSession(authOptions)) as Session | null;
if (!session?.user?.id) {
return { ok: false, response: errorResponse("인증되지 않았습니다.", 401) };
}
return { ok: true, session };
}
// ========== 봇 RPC 헬퍼 ==========
// site → bot: Redis Pub/Sub 으로 명령 전송
// bot → site: Redis SET 으로 결과 저장 (resultKey)
// 사이트는 resultKey 를 short polling 으로 확인.
export interface BotRpcOptions {
/** Redis 결과 키 prefix (e.g. "player:now") */
channel: string;
/** 봇으로 보낼 페이로드. requestId는 자동 주입됨. */
payload: Record<string, unknown>;
/** 폴링 총 타임아웃 (ms). 기본 3000. */
timeoutMs?: number;
/** 폴링 간격 (ms). 기본 100ms 시작 → 최대 400ms로 백오프. */
pollIntervalMs?: number;
/** Redis 결과 키 만료(초). 기본 5초 — 클라이언트 타임아웃 후에도 키가 남아있는 것을 방지. */
resultTtlSec?: number;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export async function botRpc(
opts: BotRpcOptions,
): Promise<{ status: number; body: Record<string, unknown> }> {
const {
channel,
payload,
timeoutMs = 3000,
pollIntervalMs = 100,
resultTtlSec = 5,
} = opts;
// CSPRNG 기반 requestId — Date.now() + Math.random() 충돌 가능성 제거
const requestId = `req:${randomUUID()}`;
const resultKey = `${channel}:${requestId}`;
// 봇에게 명령 전송 (action 필드는 호출부에서 payload 에 포함)
await Redis.publish(
"site-bot",
JSON.stringify({ ...payload, requestId }),
);
const deadline = Date.now() + timeoutMs;
let interval = pollIntervalMs;
while (Date.now() < deadline) {
await sleep(interval);
interval = Math.min(interval * 2, 400);
const reply = await Redis.get(resultKey);
if (!reply) continue;
// 읽은 즉시 정리 (TTL 도 보험으로 깔려있음)
await Redis.del(resultKey);
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(reply);
} catch (err) {
Logger.error(`[botRpc:${channel}] 봇 응답 파싱 실패: ${String(err)}`);
return {
status: 502,
body: { success: false, error: "봇 응답을 해석할 수 없습니다." },
};
}
// 봇이 success 필드를 명시한 경우만 false로 평가. 없으면 데이터 응답으로 간주(200).
const hasSuccessField = "success" in parsed;
const ok = !hasSuccessField || parsed.success === true;
return { status: ok ? 200 : 400, body: parsed };
}
// 타임아웃 — 봇이 늦게 응답해도 메모리에 쌓이지 않도록 만료 설정
// (resultKey 가 아직 없을 수 있으므로 expire 가 0 을 반환할 수 있음, 무해함)
try {
await Redis.expire(resultKey, resultTtlSec);
} catch {
// 무시: 정리 실패는 치명적 아님
}
return {
status: 504,
body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." },
};
}
// ========== POST 본문 파싱 ==========
export async function readJsonBody<T = unknown>(
request: Request,
): Promise<{ ok: true; data: T } | { ok: false; response: NextResponse }> {
try {
const data = (await request.json()) as T;
return { ok: true, data };
} catch {
return { ok: false, response: errorResponse("요청 본문이 올바른 JSON이 아닙니다.", 400) };
}
}
// ========== 필수 필드 검증 ==========
export function requireString(
value: unknown,
field: string,
): { ok: true; value: string } | { ok: false; response: NextResponse } {
if (typeof value !== "string" || !value.trim()) {
return { ok: false, response: errorResponse(`${field} 정보가 필요합니다.`, 400) };
}
return { ok: true, value: value.trim() };
}
export function requireNumber(
value: unknown,
field: string,
opts?: { min?: number; max?: number; integer?: boolean },
): { ok: true; value: number } | { ok: false; response: NextResponse } {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) {
return { ok: false, response: errorResponse(`${field} 값이 올바르지 않습니다.`, 400) };
}
if (opts?.integer && !Number.isInteger(n)) {
return { ok: false, response: errorResponse(`${field} 는 정수여야 합니다.`, 400) };
}
if (opts?.min !== undefined && n < opts.min) {
return { ok: false, response: errorResponse(`${field} 가 너무 작습니다.`, 400) };
}
if (opts?.max !== undefined && n > opts.max) {
return { ok: false, response: errorResponse(`${field} 가 너무 큽니다.`, 400) };
}
return { ok: true, value: n };
}
export function requireBoolean(
value: unknown,
field: string,
): { ok: true; value: boolean } | { ok: false; response: NextResponse } {
if (typeof value !== "boolean") {
return { ok: false, response: errorResponse(`${field} 는 boolean 이어야 합니다.`, 400) };
}
return { ok: true, value };
}

129
page/src/lib/sse.ts Normal file
View File

@@ -0,0 +1,129 @@
import { NextRequest } from "next/server";
import { getServerSession } from "next-auth";
import { Redis } from "@/lib/Redis";
import { Logger } from "@/lib/Logger";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
interface BotEvent {
event?: unknown;
guildId?: unknown;
[key: string]: unknown;
}
interface BotEventStreamOptions {
/** bot 이 발행하는 event 이름 (e.g. "player_update", "queue_update"). */
botEventName: string;
/** 클라이언트로 보낼 SSE type. 기본: botEventName 과 동일. */
clientEventType?: string;
}
/**
* 봇이 publish 하는 "bot-site" 채널을 구독해서 SSE 로 흘려보내는 공용 핸들러.
* - 인증 가드: 세션 없으면 401
* - serverId 검증
* - JSON.parse 안전 처리
* - subscriber error / 클라이언트 abort 모두에서 깔끔히 정리
* - keepalive ping (30초)
*/
export async function botEventStream(req: NextRequest, opts: BotEventStreamOptions): Promise<Response> {
const clientEventType = opts.clientEventType ?? opts.botEventName;
const session = await getServerSession(authOptions);
if (!session) {
return new Response("인증되지 않았습니다.", { status: 401 });
}
const serverId = req.nextUrl.searchParams.get("serverId")?.trim();
if (!serverId) {
return new Response("Missing serverId", { status: 400 });
}
const stream = new ReadableStream({
async start(controller) {
const subscriber = Redis.duplicate();
let closed = false;
const timers: NodeJS.Timeout[] = [];
const cleanup = async () => {
if (closed) return;
closed = true;
for (const t of timers) clearInterval(t);
try {
await subscriber.unsubscribe("bot-site");
} catch {
/* noop */
}
try {
await subscriber.quit();
} catch {
/* noop */
}
try {
controller.close();
} catch {
/* 이미 닫혔을 수 있음 */
}
};
subscriber.on("error", async (err) => {
Logger.error(`[SSE:${opts.botEventName}] subscriber error: ${err.message}`);
await cleanup();
});
try {
await subscriber.subscribe("bot-site");
} catch (err) {
Logger.error(`[SSE:${opts.botEventName}] subscribe 실패: ${String(err)}`);
await cleanup();
return;
}
subscriber.on("message", (channel, message) => {
if (channel !== "bot-site" || closed) return;
let data: BotEvent;
try {
data = JSON.parse(message) as BotEvent;
} catch (err) {
Logger.warn(`[SSE:${opts.botEventName}] 잘못된 JSON: ${String(err)}`);
return;
}
if (data.guildId !== serverId) return;
if (data.event !== opts.botEventName) return;
try {
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify({ type: clientEventType })}\n\n`),
);
} catch (err) {
Logger.warn(`[SSE:${opts.botEventName}] enqueue 실패: ${String(err)}`);
void cleanup();
}
});
// 30초마다 keep-alive 코멘트 전송 (프록시 timeout 방지)
timers.push(
setInterval(() => {
if (closed) return;
try {
controller.enqueue(new TextEncoder().encode(`: keep-alive\n\n`));
} catch {
void cleanup();
}
}, 30000),
);
// 클라이언트가 연결을 끊으면 정리
req.signal.addEventListener("abort", () => {
void cleanup();
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

42
page/src/types/music.ts Normal file
View File

@@ -0,0 +1,42 @@
// 공용 도메인 타입 — 봇/Discord/Lavalink 데이터 표현.
export interface DiscordServer {
id: string;
name: string;
icon: string | null;
owner: boolean;
permissions: string;
}
export interface TrackInfo {
identifier?: string;
title?: string;
author?: string;
length?: number;
artworkUrl?: string;
uri?: string;
}
export interface Track {
encoded?: string;
info?: TrackInfo;
// 큐 항목에는 종종 추가 메타데이터(요청자 등)가 붙음.
[key: string]: unknown;
}
// 검색 결과(곡 카드) 공용 타입 — Spotify/YT Music/YT Video 모두 매핑됨.
export interface SearchTrack {
videoId?: string;
id?: string;
url?: string;
title?: string;
artist?: string;
thumbnail?: string;
duration?: number;
}
export interface SearchResults {
spotify: SearchTrack[];
youtubeMusic: SearchTrack[];
youtubeVideo: SearchTrack[];
}

21
page/src/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
import "next-auth";
import "next-auth/jwt";
declare module "next-auth" {
interface Session {
accessToken?: string;
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
}
}
declare module "next-auth/jwt" {
interface JWT {
id?: string;
accessToken?: string;
}
}

View File

@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,