diff --git a/bot/src/classes/RedisClient.ts b/bot/src/classes/RedisClient.ts index aeec4b9..0d29a5d 100644 --- a/bot/src/classes/RedisClient.ts +++ b/bot/src/classes/RedisClient.ts @@ -142,7 +142,8 @@ class RedisClientClass { const resultKey = `queue:remove:${data.requestId}`; if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); - if (!data.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); if (!context.ok) return; const numIndex = Number(data.index); @@ -159,7 +160,8 @@ class RedisClientClass { const resultKey = `player:paused:${data.requestId}`; if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); - if (!data.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); if (!context.ok) return; await context.player.setPause(); @@ -178,7 +180,8 @@ class RedisClientClass { const resultKey = `player:seek:${data.requestId}`; if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); - if (!data.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); if (!context.ok) return; 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}`; 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.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); if (!context.ok) return; if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." })); diff --git a/page/next.config.ts b/page/next.config.ts index 5505fcd..d844b5f 100644 --- a/page/next.config.ts +++ b/page/next.config.ts @@ -1,12 +1,38 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ - allowedDevOrigins: [ - "192.168.10.13", - "localhost", - "music.tkrmagid.kr" - ] + // 보안 헤더 + async headers() { + return [ + { + 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; diff --git a/page/package-lock.json b/page/package-lock.json index 2483716..92f1685 100644 --- a/page/package-lock.json +++ b/page/package-lock.json @@ -11,7 +11,7 @@ "colors": "^1.4.0", "ioredis": "^5.10.1", "lucide-react": "^1.7.0", - "next": "16.2.2", + "next": "^16.2.4", "next-auth": "^4.24.13", "react": "19.2.4", "react-dom": "19.2.4" @@ -22,7 +22,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.2", + "eslint-config-next": "^16.2.4", "tailwindcss": "^4", "typescript": "^5" } @@ -611,9 +611,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -630,9 +627,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -649,9 +643,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -668,9 +659,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -687,9 +675,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -706,9 +691,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -725,9 +707,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -744,9 +723,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -763,9 +739,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -788,9 +761,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -813,9 +783,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -838,9 +805,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -863,9 +827,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -888,9 +849,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -913,9 +871,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -938,9 +893,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1102,15 +1054,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz", - "integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.2.tgz", - "integrity": "sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz", + "integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==", "dev": true, "license": "MIT", "dependencies": { @@ -1118,9 +1070,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz", - "integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", "cpu": [ "arm64" ], @@ -1134,9 +1086,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz", - "integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", "cpu": [ "x64" ], @@ -1150,15 +1102,12 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz", - "integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1169,15 +1118,12 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz", - "integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1188,15 +1134,12 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz", - "integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1207,15 +1150,12 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz", - "integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1226,9 +1166,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz", - "integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", "cpu": [ "arm64" ], @@ -1242,9 +1182,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz", - "integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", "cpu": [ "x64" ], @@ -1463,9 +1403,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1483,9 +1420,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1503,9 +1437,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1523,9 +1454,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2076,9 +2004,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2093,9 +2018,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2110,9 +2032,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2127,9 +2046,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2144,9 +2060,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2161,9 +2074,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2178,9 +2088,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2195,9 +2102,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3270,13 +3174,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.2.tgz", - "integrity": "sha512-6VlvEhwoug2JpVgjZDhyXrJXUEuPY++TddzIpTaIRvlvlXXFgvQUtm3+Zr84IjFm0lXtJt73w19JA08tOaZVwg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz", + "integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==", "dev": true, "license": "MIT", "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-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -4873,9 +4777,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4897,9 +4798,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4921,9 +4819,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4945,9 +4840,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5185,12 +5077,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz", - "integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", "license": "MIT", "dependencies": { - "@next/env": "16.2.2", + "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -5204,14 +5096,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.2", - "@next/swc-darwin-x64": "16.2.2", - "@next/swc-linux-arm64-gnu": "16.2.2", - "@next/swc-linux-arm64-musl": "16.2.2", - "@next/swc-linux-x64-gnu": "16.2.2", - "@next/swc-linux-x64-musl": "16.2.2", - "@next/swc-win32-arm64-msvc": "16.2.2", - "@next/swc-win32-x64-msvc": "16.2.2", + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { diff --git a/page/package.json b/page/package.json index 3bd2317..9d12c36 100644 --- a/page/package.json +++ b/page/package.json @@ -12,7 +12,7 @@ "colors": "^1.4.0", "ioredis": "^5.10.1", "lucide-react": "^1.7.0", - "next": "16.2.2", + "next": "^16.2.4", "next-auth": "^4.24.13", "react": "19.2.4", "react-dom": "19.2.4" @@ -23,7 +23,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.2", + "eslint-config-next": "^16.2.4", "tailwindcss": "^4", "typescript": "^5" } diff --git a/page/src/app/api/auth/[...nextauth]/route.ts b/page/src/app/api/auth/[...nextauth]/route.ts index d17448a..667f4d2 100644 --- a/page/src/app/api/auth/[...nextauth]/route.ts +++ b/page/src/app/api/auth/[...nextauth]/route.ts @@ -1,11 +1,30 @@ import NextAuth, { NextAuthOptions } from "next-auth"; 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 = { + secret: NEXTAUTH_SECRET, providers: [ DiscordProvider({ - clientId: process.env.DISCORD_CLIENT_ID as string, - clientSecret: process.env.DISCORD_CLIENT_SECRET as string, + clientId: DISCORD_CLIENT_ID, + clientSecret: DISCORD_CLIENT_SECRET, // 🌟 핵심: 로그인할 때 유저의 기본 정보(identify)와 서버 목록(guilds) 권한을 같이 가져옵니다! authorization: { params: { scope: "identify email guilds" } }, }), @@ -16,15 +35,16 @@ export const authOptions: NextAuthOptions = { callbacks: { // 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직 async jwt({ token, account, profile }) { - if (account && (profile as any)?.id) { - token.id = (profile as any).id; + const discordProfile = profile as DiscordProfile | undefined; + if (account && discordProfile?.id) { + token.id = discordProfile.id; token.accessToken = account.access_token; } return token; }, - async session({ session, token }: any) { - session.user.id = token.id; - session.accessToken = token.accessToken; + async session({ session, token }) { + if (token.id) session.user.id = token.id; + if (token.accessToken) session.accessToken = token.accessToken; return session; }, }, @@ -33,4 +53,4 @@ export const authOptions: NextAuthOptions = { const handler = NextAuth(authOptions); // App Router 환경에서는 GET과 POST 메서드를 둘 다 내보내야 합니다. -export { handler as GET, handler as POST }; \ No newline at end of file +export { handler as GET, handler as POST }; diff --git a/page/src/app/api/player/events/route.ts b/page/src/app/api/player/events/route.ts index b3f6c93..e0a5fb8 100644 --- a/page/src/app/api/player/events/route.ts +++ b/page/src/app/api/player/events/route.ts @@ -1,54 +1,10 @@ -// src/app/api/queue/events/route.ts +// src/app/api/player/events/route.ts import { NextRequest } from "next/server"; -import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트 +import { botEventStream } from "@/lib/sse"; // 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다. export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { - // 프론트엔드에서 보낸 serverId 가져오기 - 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", - }, - }); -} \ No newline at end of file + return botEventStream(req, { botEventName: "player_update" }); +} diff --git a/page/src/app/api/player/now/route.ts b/page/src/app/api/player/now/route.ts index a4b2893..06439ce 100644 --- a/page/src/app/api/player/now/route.ts +++ b/page/src/app/api/player/now/route.ts @@ -1,42 +1,34 @@ 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) { try { - const body = await request.json(); - const { serverId, userId } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `player:now:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'player_now' 명령 전송 - await Redis.publish("site-bot", JSON.stringify({ - action: "player_now", - requestId: requestId, - serverId: serverId, - userId: userId, - })); - - // 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: "player:now", + payload: { + action: "player_now", + serverId: serverIdResult.value, + userId, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Play API Error:", error); - return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`player/now API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/player/pause/route.ts b/page/src/app/api/player/pause/route.ts index b6b7023..4abd454 100644 --- a/page/src/app/api/player/pause/route.ts +++ b/page/src/app/api/player/pause/route.ts @@ -1,43 +1,46 @@ 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) { try { - const body = await request.json(); - const { serverId, userId, isPaused } = body; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); - if (!isPaused) return NextResponse.json({ error: "isPaused 정보가 필요합니다." }, { status: 400 }); + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `player:paused:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - // 봇에게 'player_pause' 명령 전송 - await Redis.publish("site-bot", JSON.stringify({ - action: "player_paused", - requestId: requestId, - serverId: serverId, - userId: userId, - isPaused: isPaused, - })); + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 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 }); - } - } + const isPausedResult = requireBoolean(bodyResult.data.isPaused, "isPaused"); + if (!isPausedResult.ok) return isPausedResult.response; - // 3초가 지나도 봇이 묵묵부답일 때 - return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 }); + const { status, body } = await botRpc({ + channel: "player:paused", + payload: { + action: "player_paused", + serverId: serverIdResult.value, + userId, + isPaused: isPausedResult.value, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Play API Error:", error); - return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`player/pause API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/player/play/route.ts b/page/src/app/api/player/play/route.ts index d64eda5..e92f68b 100644 --- a/page/src/app/api/player/play/route.ts +++ b/page/src/app/api/player/play/route.ts @@ -1,44 +1,39 @@ 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) { try { - const body = await request.json(); - const { serverId, userId, track } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); - if (!track) return NextResponse.json({ error: "track 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `player:play:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'player_play' 명령 전송 - await Redis.publish("site-bot", JSON.stringify({ - action: "player_play", - requestId: requestId, - serverId: serverId, - userId: userId, - track: track, - })); + const track = bodyResult.data.track; + if (!track || typeof track !== "object") return errorResponse("track 정보가 필요합니다."); - // 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: "player:play", + payload: { + action: "player_play", + serverId: serverIdResult.value, + userId, + track, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Play API Error:", error); - return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`player/play API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/player/playlist/route.ts b/page/src/app/api/player/playlist/route.ts index 5acbe1f..f3f5a3b 100644 --- a/page/src/app/api/player/playlist/route.ts +++ b/page/src/app/api/player/playlist/route.ts @@ -1,44 +1,39 @@ 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) { try { - const body = await request.json(); - const { serverId, userId, playlistUrl } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); - if (!playlistUrl) return NextResponse.json({ error: "playlistUrl 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `player:playlist:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'player_playlist' 명령 전송 - await Redis.publish("site-bot", JSON.stringify({ - action: "player_playlist", - requestId: requestId, - serverId: serverId, - userId: userId, - playlistUrl: playlistUrl, - })); + const urlResult = requireString(bodyResult.data.playlistUrl, "playlistUrl"); + if (!urlResult.ok) return urlResult.response; - // 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: "player:playlist", + payload: { + action: "player_playlist", + serverId: serverIdResult.value, + userId, + playlistUrl: urlResult.value, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Queue Adds API Error:", error); - return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`player/playlist API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/player/seek/route.ts b/page/src/app/api/player/seek/route.ts index 7f7116e..fdf3159 100644 --- a/page/src/app/api/player/seek/route.ts +++ b/page/src/app/api/player/seek/route.ts @@ -1,44 +1,47 @@ 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) { try { - const body = await request.json(); - const { serverId, userId, seek } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); - if (!seek) return NextResponse.json({ error: "seek 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `player:seek:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'player_seek' 명령 전송 - await Redis.publish("site-bot", JSON.stringify({ - action: "player_seek", - requestId: requestId, - serverId: serverId, - userId: userId, - seek: seek, - })); + // seek 는 0(처음으로 되감기) 도 정상 입력. requireNumber 는 0 허용. + const seekResult = requireNumber(bodyResult.data.seek, "seek", { min: 0, integer: true }); + if (!seekResult.ok) return seekResult.response; - // 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: "player:seek", + payload: { + action: "player_seek", + serverId: serverIdResult.value, + userId, + seek: seekResult.value, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Play API Error:", error); - return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`player/seek API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/player/skip/route.ts b/page/src/app/api/player/skip/route.ts index 25bdee1..d2d15b3 100644 --- a/page/src/app/api/player/skip/route.ts +++ b/page/src/app/api/player/skip/route.ts @@ -1,42 +1,34 @@ 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) { try { - const body = await request.json(); - const { serverId, userId } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `player:skip:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'player_skip' 명령 전송 - await Redis.publish("site-bot", JSON.stringify({ - action: "player_skip", - requestId: requestId, - serverId: serverId, - userId: userId, - })); - - // 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: "player:skip", + payload: { + action: "player_skip", + serverId: serverIdResult.value, + userId, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Play API Error:", error); - return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`player/skip API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/player/volume/route.ts b/page/src/app/api/player/volume/route.ts index 6d70f15..9360567 100644 --- a/page/src/app/api/player/volume/route.ts +++ b/page/src/app/api/player/volume/route.ts @@ -1,44 +1,50 @@ 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) { try { - const body = await request.json(); - const { serverId, userId, volume } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); - if (!volume) return NextResponse.json({ error: "volume 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `player:volume:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'player_volume' 명령 전송 - await Redis.publish("site-bot", JSON.stringify({ - action: "player_volume", - requestId: requestId, - serverId: serverId, - userId: userId, - volume: volume, - })); + const volumeResult = requireNumber(bodyResult.data.volume, "volume", { + min: 0, + max: 100, + integer: true, + }); + if (!volumeResult.ok) return volumeResult.response; - // 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: "player:volume", + payload: { + action: "player_volume", + serverId: serverIdResult.value, + userId, + volume: volumeResult.value, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Play API Error:", error); - return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`player/volume API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/queue/events/route.ts b/page/src/app/api/queue/events/route.ts index 0a374c6..927333b 100644 --- a/page/src/app/api/queue/events/route.ts +++ b/page/src/app/api/queue/events/route.ts @@ -1,54 +1,10 @@ // src/app/api/queue/events/route.ts import { NextRequest } from "next/server"; -import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트 +import { botEventStream } from "@/lib/sse"; // 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다. export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { - // 프론트엔드에서 보낸 serverId 가져오기 - 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", - }, - }); -} \ No newline at end of file + return botEventStream(req, { botEventName: "queue_update" }); +} diff --git a/page/src/app/api/queue/list/route.ts b/page/src/app/api/queue/list/route.ts index 8fb6667..b166c3a 100644 --- a/page/src/app/api/queue/list/route.ts +++ b/page/src/app/api/queue/list/route.ts @@ -1,44 +1,34 @@ 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) { try { - const body = await request.json(); - const { serverId, userId } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - // 1. 고유한 요청 ID(진동벨) 생성 - const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; - 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 serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; + const { status, body } = await botRpc({ + channel: "queue:list", + payload: { + action: "queue_list", + serverId: serverIdResult.value, + userId, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Queue List API Error:", error); - return NextResponse.json({ success: false, message: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`queue/list API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/queue/remove/route.ts b/page/src/app/api/queue/remove/route.ts index c91a2a6..523a10b 100644 --- a/page/src/app/api/queue/remove/route.ts +++ b/page/src/app/api/queue/remove/route.ts @@ -1,45 +1,48 @@ 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) { try { - const body = await request.json(); - const { serverId, index, userId } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); - if (!index) return NextResponse.json({ error: "index 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `queue:remove:${requestId}`; + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'remove_queue' 명령 발송 (몇 번째 인덱스를 지워라) - await Redis.publish("site-bot", JSON.stringify({ - action: "queue_remove", - serverId: serverId, - requestId: requestId, - userId: userId, - index: index, - })); + // index 0 도 정상값 + const indexResult = requireNumber(bodyResult.data.index, "index", { min: 0, integer: true }); + if (!indexResult.ok) return indexResult.response; - // 4. 결과가 올라올 때까지 기다리기 (Polling) - // 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다. - for (let i = 0; i < 10; i++) { - // 0.5초 대기 - await new Promise(resolve => setTimeout(resolve, 500)); - - // Redis 게시판 확인 - const resultData = await Redis.get(resultKey); - - if (resultData) { - // 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료 - return NextResponse.json(JSON.parse(resultData)); - } - } - - // 5초가 지나도 응답이 없으면 타임아웃 - return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 }); + const { status, body } = await botRpc({ + channel: "queue:remove", + payload: { + action: "queue_remove", + serverId: serverIdResult.value, + userId, + index: indexResult.value, + }, + timeoutMs: 5000, + }); + return NextResponse.json(body, { status }); } catch (error) { - return NextResponse.json({ error: "서버 오류" }, { status: 500 }); + Logger.error(`queue/remove API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/queue/set/route.ts b/page/src/app/api/queue/set/route.ts index c517dba..7469957 100644 --- a/page/src/app/api/queue/set/route.ts +++ b/page/src/app/api/queue/set/route.ts @@ -1,46 +1,40 @@ 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) { try { - const body = await request.json(); - const { serverId, newQueue, userId } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); - if (newQueue === undefined || newQueue === null) return NextResponse.json({ error: "newQueue 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `queue:set:${requestId}`; + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'queue_set' 명령 발송 (전체 대기열을 통째로 덮어써라!) - await Redis.publish("site-bot", JSON.stringify({ - action: "queue_set", - serverId: serverId, - requestId: requestId, - userId: userId, - newQueue: newQueue, - })); + const newQueue = bodyResult.data.newQueue; + if (!Array.isArray(newQueue)) return errorResponse("newQueue 정보가 필요합니다."); - // 4. 결과가 올라올 때까지 기다리기 (Polling) - // 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다. - for (let i = 0; i < 10; i++) { - // 0.5초 대기 - await new Promise(resolve => setTimeout(resolve, 500)); - - // Redis 게시판 확인 - const resultData = await Redis.get(resultKey); - - if (resultData) { - // 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료 - return NextResponse.json(JSON.parse(resultData)); - } - } - - // 5초가 지나도 응답이 없으면 타임아웃 - return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 }); + const { status, body } = await botRpc({ + channel: "queue:set", + payload: { + action: "queue_set", + serverId: serverIdResult.value, + userId, + newQueue, + }, + timeoutMs: 5000, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Queue Reorder API Error:", error); - return NextResponse.json({ error: "서버 오류" }, { status: 500 }); + Logger.error(`queue/set API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/search/route.ts b/page/src/app/api/search/route.ts index 7793e07..956db0d 100644 --- a/page/src/app/api/search/route.ts +++ b/page/src/app/api/search/route.ts @@ -1,42 +1,30 @@ // src/app/api/search/route.ts 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) { - // 1. 검색어(query) 가져오기 - const { searchParams } = new URL(request.url); - const query = searchParams.get("q"); + try { + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; - if (!query) { - return NextResponse.json({ error: "검색어가 없습니다." }, { status: 400 }); + const { searchParams } = new URL(request.url); + 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 }); -} \ No newline at end of file +} diff --git a/page/src/app/api/servers/route.ts b/page/src/app/api/servers/route.ts index 2a3892c..e544c3a 100644 --- a/page/src/app/api/servers/route.ts +++ b/page/src/app/api/servers/route.ts @@ -1,34 +1,58 @@ import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; 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() { - const session = await getServerSession(authOptions) as any; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; - if (!session || !session.accessToken) { - return NextResponse.json({ error: "인증되지 않았습니다." }, { status: 401 }); + const accessToken = sessionResult.session.accessToken; + if (!accessToken) { + return NextResponse.json({ success: false, error: "Discord 액세스 토큰이 없습니다." }, { status: 401 }); } try { // 1. 디스코드 API에서 유저가 속한 서버 목록 가져오기 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에서 봇이 속한 서버 목록(화이트리스트) 가져오기 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. 🌟 두 목록을 비교해서 봇이 있는 서버만 필터링! - const filteredGuilds = userGuilds.filter((guild: any) => - botGuildIds.includes(guild.id) - ); + const filteredGuilds = userGuilds.filter((guild) => botGuildIds.includes(guild.id)); return NextResponse.json(filteredGuilds); } catch (error) { - console.error("서버 필터링 에러:", error); - return NextResponse.json({ error: "서버 목록을 가져오지 못했습니다." }, { status: 500 }); + Logger.error(`서버 필터링 에러: ${error instanceof Error ? error.message : String(error)}`); + return NextResponse.json({ success: false, error: "서버 목록을 가져오지 못했습니다." }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/page/src/app/page.tsx b/page/src/app/page.tsx index d4eaff0..201b0e4 100644 --- a/page/src/app/page.tsx +++ b/page/src/app/page.tsx @@ -6,13 +6,14 @@ import LeftSidebar from "@/components/layout/LeftSidebar"; import MainContent from "@/components/player/MainContent"; import QueueSidebar from "@/components/player/QueueSidebar"; import PlayerBar from "@/components/player/PlayerBar"; +import type { DiscordServer } from "@/types/music"; // 화면 모드 타입 정의 export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT"; export default function MusicPlayerLayout() { const [viewMode, setViewMode] = useState("SERVER_LIST"); - const [selectedServer, setSelectedServer] = useState(null); + const [selectedServer, setSelectedServer] = useState(null); const [searchQuery, setSearchQuery] = useState(""); // 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀 @@ -33,7 +34,7 @@ export default function MusicPlayerLayout() { }; // 서버 선택 시 - const handleSelectServer = (server: any) => { + const handleSelectServer = (server: DiscordServer) => { setSelectedServer(server); setViewMode("SERVER_DETAIL"); }; diff --git a/page/src/components/layout/LeftSidebar.tsx b/page/src/components/layout/LeftSidebar.tsx index 011171c..6d2703b 100644 --- a/page/src/components/layout/LeftSidebar.tsx +++ b/page/src/components/layout/LeftSidebar.tsx @@ -1,25 +1,20 @@ "use client"; -import { ListMusic, Library } from "lucide-react"; +import { ListMusic } from "lucide-react"; export default function LeftSidebar() { return ( ); -} \ No newline at end of file +} diff --git a/page/src/components/layout/TopNav.tsx b/page/src/components/layout/TopNav.tsx index 179a3bf..c0f14ad 100644 --- a/page/src/components/layout/TopNav.tsx +++ b/page/src/components/layout/TopNav.tsx @@ -2,11 +2,12 @@ import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react"; import { signIn, signOut, useSession } from "next-auth/react"; import { useState } from "react"; +import type { DiscordServer } from "@/types/music"; interface TopNavProps { onSearch: (query: string) => void; onHome: () => void; - selectedServer: any; // 🌟 추가: 선택된 서버 정보 + selectedServer: DiscordServer | null; // 🌟 추가: 선택된 서버 정보 } export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) { diff --git a/page/src/components/player/MainContent.tsx b/page/src/components/player/MainContent.tsx index cebb1b3..e1440a3 100644 --- a/page/src/components/player/MainContent.tsx +++ b/page/src/components/player/MainContent.tsx @@ -5,21 +5,16 @@ import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc } import { ViewMode } from "@/app/page"; // 🌟 [추가됨] 전역 토스트 훅 불러오기 import { useToast } from "@/components/ToastProvider"; +import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music"; interface MainContentProps { viewMode: ViewMode; setViewMode: (mode: ViewMode) => void; - selectedServer: any; - setSelectedServer: (server: any) => void; + selectedServer: DiscordServer | null; + setSelectedServer: (server: DiscordServer | null) => void; searchQuery: string; setSearchQuery: (query: string) => void; - onSelectServer: (server: any) => void; -} - -interface SearchResultsType { - spotify: any[]; - youtubeMusic: any[]; - youtubeVideo: any[]; + onSelectServer: (server: DiscordServer) => void; } export default function MainContent({ @@ -36,27 +31,31 @@ export default function MainContent({ // 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기 const { showToast } = useToast(); - const [servers, setServers] = useState([]); + const [servers, setServers] = useState([]); const [isFetching, setIsFetching] = useState(false); - const [searchResults, setSearchResults] = useState({ + const [searchResults, setSearchResults] = useState({ spotify: [], youtubeMusic: [], youtubeVideo: [] }); const [isSearching, setIsSearching] = useState(false); - const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: any, playlistUrl?: string) => { + const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => { if (!selectedServer) { // 🌟 alert -> showToast 교체 showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error"); return; } - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; + if (!userId) { + showToast("로그인이 필요합니다.", "error"); + return; + } let endpoint = ""; - let bodyData: any = { serverId: selectedServer.id, userId: userId }; + const bodyData: Record = { serverId: selectedServer.id, userId }; if (actionType === 'player_play') { endpoint = "/api/player/play"; bodyData.track = track; @@ -88,13 +87,16 @@ export default function MainContent({ } }; - const getPermissionLabel = (server: any) => { + const getPermissionLabel = (server: DiscordServer | null) => { if (!server) return "알 수 없음"; if (server.owner) return "👑 서버 주인"; + // Discord permissions 는 큰 정수 문자열로 도착함. 숫자/문자열만 받아 안전하게 BigInt 화. + const raw: unknown = server.permissions; + if (typeof raw !== "string" && typeof raw !== "number") return "👤 일반 멤버"; try { - const perms = BigInt(server.permissions); - if ((perms & BigInt(0x8)) === BigInt(0x8)) return "🛠️ 관리자"; - if ((perms & BigInt(0x20)) === BigInt(0x20)) return "⚙️ 매니저"; + const perms = BigInt(raw); + if ((perms & 0x8n) === 0x8n) return "🛠️ 관리자"; + if ((perms & 0x20n) === 0x20n) return "⚙️ 매니저"; return "👤 일반 멤버"; } catch { return "👤 일반 멤버"; @@ -159,7 +161,7 @@ export default function MainContent({ const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0; - const renderTrackCard = (track: any) => ( + const renderTrackCard = (track: SearchTrack) => (
{track.thumbnail && {track.title}} @@ -258,7 +260,7 @@ export default function MainContent({ {/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */} {viewMode === "SEARCH_RESULT" && (
-

"{searchQuery}" 검색 결과

+

“{searchQuery}” 검색 결과

입력하신 검색어에 대한 플랫폼별 결과입니다.

{isSearching ? ( diff --git a/page/src/components/player/PlayerBar.tsx b/page/src/components/player/PlayerBar.tsx index 0457646..4e676c6 100644 --- a/page/src/components/player/PlayerBar.tsx +++ b/page/src/components/player/PlayerBar.tsx @@ -2,16 +2,17 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react"; import { useSession } from "next-auth/react"; +import type { DiscordServer, Track } from "@/types/music"; interface PlayerBarProps { - selectedServer: any; + selectedServer: DiscordServer | null; } export default function PlayerBar({ selectedServer }: PlayerBarProps) { const { data: session } = useSession(); // 재생 상태 관리 - const [track, setTrack] = useState(null); + const [track, setTrack] = useState(null); const [botPlayer, setBotPlayer] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const [isPaused, setIsPaused] = useState(false); @@ -28,7 +29,7 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) { const fetchNowPlaying = useCallback(async () => { if (!selectedServer) return; - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; if (!userId) return; try { @@ -43,12 +44,12 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) { const data = await res.json(); if (res.ok && data.success && data.track) { - setBotPlayer(data.botPlayer); - setIsPlaying(data.isPlaying); - setIsPaused(data.isPaused); - setTrack(data.track); - setDuration(data.track.info.length || 0); - setVolume(data.volume ?? 50); + setBotPlayer(Boolean(data.botPlayer)); + setIsPlaying(Boolean(data.isPlaying)); + setIsPaused(Boolean(data.isPaused)); + setTrack(data.track as Track); + setDuration(Number(data.track?.info?.length ?? 0) || 0); + setVolume(typeof data.volume === "number" ? data.volume : 50); // 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐) if (!isDragging.current) { setPosition(data.position || 0); @@ -67,41 +68,53 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) { // 2. 초기 로드 및 SSE 실시간 업데이트 수신 useEffect(() => { if (!selectedServer) return; + // 서버 선택 시 1회 즉시 동기화 — 의도적 패턴. + // eslint-disable-next-line react-hooks/set-state-in-effect fetchNowPlaying(); // 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호 const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`); eventSource.onmessage = (event) => { - const data = JSON.parse(event.data); - if (data.type === "player_update") { - fetchNowPlaying(); + try { + const data = JSON.parse(event.data); + if (data?.type === "player_update") { + fetchNowPlaying(); + } + } catch (err) { + console.warn("SSE JSON 파싱 실패:", err); } }; + eventSource.onerror = (error) => { + console.error("Player SSE 연결 오류:", error); + eventSource.close(); + }; return () => eventSource.close(); }, [selectedServer, fetchNowPlaying]); // 3. 🌟 로컬 1초 타이머 & 10초 서버 동기화 통합 (재생 중일 때만 작동!) + // isPaused 상태를 ref 로 들고 있어서, interval 콜백이 항상 최신 값을 읽도록 처리. + const isPausedRef = useRef(isPaused); useEffect(() => { - let localInterval: NodeJS.Timeout; - let syncInterval: NodeJS.Timeout; + isPausedRef.current = isPaused; + }, [isPaused]); - // 노래가 재생 중이고, 유저가 재생바를 잡고 있지 않을 때만 타이머들을 가동합니다. - if (isPlaying && !isDragging.current) { + useEffect(() => { + if (!isPlaying) return; - // ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용) - localInterval = setInterval(() => { - if (!isPaused) setPosition((prev) => { - if (prev >= duration) return duration; - return prev + 1000; - }); - }, 1000); + // ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용) + const localInterval = setInterval(() => { + if (isPausedRef.current || isDragging.current) return; + setPosition((prev) => { + if (prev >= duration) return duration; + return prev + 1000; + }); + }, 1000); - // ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용) - syncInterval = setInterval(() => { - if (!isPaused) fetchNowPlaying(); - }, 10000); - - } + // ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용) + const syncInterval = setInterval(() => { + if (isPausedRef.current || isDragging.current) return; + fetchNowPlaying(); + }, 10000); // 일시정지되거나 컴포넌트가 꺼지면 두 타이머 모두 깔끔하게 청소합니다. return () => { @@ -117,9 +130,11 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) { const handleTogglePause = async () => { if (!selectedServer || !track) return; if (!isPlaying) return; - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; + if (!userId) return; + const nextPaused = !isPaused; // UI 즉각 반영 (Optimistic UI) - setIsPaused(!isPaused); + setIsPaused(nextPaused); try { const res = await fetch('/api/player/pause', { method: 'POST', @@ -127,27 +142,28 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) { body: JSON.stringify({ serverId: selectedServer.id, userId: userId, - isPaused: String(isPaused), + isPaused: nextPaused, // boolean 그대로 전송 }) }); const data = await res.json(); if (res.ok && data.success) { - if (data.isPaused?.trim().toLocaleLowerCase() === "true") { - setIsPaused(true); - } else { - setIsPaused(false); - } + // 봇이 실제로 적용된 paused 상태를 돌려줌. 없으면 낙관적 값 유지. + if (typeof data.paused === "boolean") setIsPaused(data.paused); + } else { + // 실패 시 롤백 + setIsPaused(!nextPaused); } } catch (error) { console.error("일시정지 에러:", error); - setIsPaused(!isPaused); // 실패 시 롤백 + setIsPaused(!nextPaused); // 실패 시 롤백 } }; // 다음 곡 스킵 (player_skip) const handleSkip = async () => { if (!selectedServer || !track) return; - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; + if (!userId) return; try { await fetch('/api/player/skip', { method: 'POST', @@ -167,7 +183,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) { // 🌟 [수정됨] 마우스 이벤트와 터치 이벤트를 모두 허용하도록 타입 변경 const handleSeekEnd = async (e: React.MouseEvent | React.TouchEvent) => { if (!selectedServer || !track) return; - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; + if (!userId) return; isDragging.current = false; // 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다. @@ -198,7 +215,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) { const handleVolumeEnd = async (e: React.MouseEvent | React.TouchEvent) => { setIsVolumeDragging(false); if (!selectedServer) return; - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; + if (!userId) return; const finalVolume = Number(e.currentTarget.value); setVolume(finalVolume); // UI 즉시 반영 diff --git a/page/src/components/player/QueueSidebar.tsx b/page/src/components/player/QueueSidebar.tsx index 9d5fde9..02e4ce0 100644 --- a/page/src/components/player/QueueSidebar.tsx +++ b/page/src/components/player/QueueSidebar.tsx @@ -3,9 +3,10 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { Trash2, GripVertical, Music } from "lucide-react"; import { useSession } from "next-auth/react"; import { useToast } from "@/components/ToastProvider"; +import type { DiscordServer, Track } from "@/types/music"; interface QueueSidebarProps { - selectedServer: any; + selectedServer: DiscordServer | null; } export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { @@ -14,8 +15,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { // 👇 [추가] 토스트 사용 준비 완료! const { showToast } = useToast(); - const [queue, setQueue] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [queue, setQueue] = useState([]); const [draggingIndex, setDraggingIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); @@ -26,10 +26,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { const fetchQueue = useCallback(async () => { if (!selectedServer) return; - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; if (!userId) return; - setIsLoading(true); try { const res = await fetch('/api/queue/list', { method: 'POST', @@ -41,29 +40,33 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { }); const data = await res.json(); if (res.ok && data.success && Array.isArray(data.queue)) { - setQueue(data.queue); + setQueue(data.queue as Track[]); } else { setQueue([]); } } catch (error) { console.error("큐 불러오기 실패:", error); setQueue([]); - } finally { - setIsLoading(false); } }, [selectedServer, session]); useEffect(() => { if (status === "loading" || !selectedServer) return; + // 서버 선택 시 1회 즉시 동기화 — 의도적 패턴. + // eslint-disable-next-line react-hooks/set-state-in-effect fetchQueue(); const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`); eventSource.onmessage = (event) => { - const data = JSON.parse(event.data); - if (data.type === "queue_update") { - fetchQueue(); + try { + const data = JSON.parse(event.data); + if (data?.type === "queue_update") { + fetchQueue(); + } + } catch (err) { + console.warn("SSE JSON 파싱 실패:", err); } }; @@ -106,7 +109,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { } // --- 여기서부터는 진짜 순서가 바뀌었을 때만 실행됩니다 --- - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; // 3. 화면 즉시 업데이트 const newQueue = [...queue]; @@ -139,7 +142,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { const handleDelete = async (indexToRemove: number) => { if (!selectedServer) return; - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; const newQueue = queue.filter((_, index) => index !== indexToRemove); setQueue(newQueue); diff --git a/page/src/lib/Logger.ts b/page/src/lib/Logger.ts index 9d4ace1..857a197 100644 --- a/page/src/lib/Logger.ts +++ b/page/src/lib/Logger.ts @@ -1,28 +1,43 @@ import colors from "colors/safe"; +// Asia/Seoul(UTC+9) 타임스탬프. ISO 포맷에서 안전하게 추출. export const Timestamp = () => { - const Now = new Date(); - Now.setHours(Now.getHours() + 9); - return Now.toISOString().replace('T', ' ').substring(0, 19).slice(2); -} + const now = new Date(Date.now() + 9 * 60 * 60 * 1000); + // YYYY-MM-DDTHH:mm:ss.sssZ 에서 YY-MM-DD HH:mm:ss + 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 line = `${label} ${timestamp} ${content}`; + if (useStderr) console.error(line); + else console.log(line); +}; + +const log = (content: string, type: LogType) => { switch (type) { case "log": - return console.log(`${colors.gray("[LOG]")} ${timestamp} ${content}`); + // 일반 디버그성 로그는 프로덕션에서 숨김 + if (isProd) return; + return write(colors.gray("[LOG]"), content, false); case "info": - return console.log(`${colors.cyan("[INFO]")} ${timestamp} ${content}`); + return write(colors.cyan("[INFO]"), content, false); case "warn": - return console.log(`${colors.yellow("[WARN]")} ${timestamp} ${content}`); + return write(colors.yellow("[WARN]"), content, true); case "error": - return console.log(`${colors.red("[ERROR]")} ${timestamp} ${content}`); + return write(colors.red("[ERROR]"), content, true); case "debug": - return console.log(`${colors.magenta("[DEBUG]")} ${timestamp} ${content}`); + if (isProd) return; + return write(colors.magenta("[DEBUG]"), content, false); case "ready": - return console.log(`${colors.green("[READY]")} ${timestamp} ${content}`); + return write(colors.green("[READY]"), content, false); default: throw new TypeError("Logger 타입이 올바르지 않습니다."); } @@ -34,5 +49,5 @@ export const Logger = { error: (content: string) => log(content, "error"), debug: (content: string) => log(content, "debug"), info: (content: string) => log(content, "info"), - ready: (content: string) => log(content, "ready") -} \ No newline at end of file + ready: (content: string) => log(content, "ready"), +}; diff --git a/page/src/lib/Redis.ts b/page/src/lib/Redis.ts index 27cad17..d2fc3fe 100644 --- a/page/src/lib/Redis.ts +++ b/page/src/lib/Redis.ts @@ -1,7 +1,16 @@ import { Redis as RedisClass } from "ioredis"; +import { Logger } from "@/lib/Logger"; -// .env.local 파일에서 설정한 IP를 가져옵니다. (기본값으로 Proxmox IP 세팅) -const REDIS_HOST = process.env.REDIS_HOST || "192.168.10.7"; +// Redis 호스트는 환경변수에서 가져옵니다. 미설정 시 부팅 시점에 명확하게 에러를 던집니다. +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)에서 커넥션이 무한 증식하는 것을 막기 위한 글로벌 객체 선언 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; // 연결 성공 시 로그 한 번만 찍기 +let connectLogged = false; 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) => { - console.error("❌ [Next.js] Redis 연결 에러:", err); -}); \ No newline at end of file + Logger.error(`Redis 연결 에러: ${err instanceof Error ? err.message : String(err)}`); +}); diff --git a/page/src/lib/api.ts b/page/src/lib/api.ts new file mode 100644 index 0000000..0c64c11 --- /dev/null +++ b/page/src/lib/api.ts @@ -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; + /** 폴링 총 타임아웃 (ms). 기본 3000. */ + timeoutMs?: number; + /** 폴링 간격 (ms). 기본 100ms 시작 → 최대 400ms로 백오프. */ + pollIntervalMs?: number; + /** Redis 결과 키 만료(초). 기본 5초 — 클라이언트 타임아웃 후에도 키가 남아있는 것을 방지. */ + resultTtlSec?: number; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export async function botRpc( + opts: BotRpcOptions, +): Promise<{ status: number; body: Record }> { + 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; + 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( + 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 }; +} diff --git a/page/src/lib/sse.ts b/page/src/lib/sse.ts new file mode 100644 index 0000000..673220a --- /dev/null +++ b/page/src/lib/sse.ts @@ -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 { + 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", + }, + }); +} diff --git a/page/src/types/music.ts b/page/src/types/music.ts new file mode 100644 index 0000000..e7583ee --- /dev/null +++ b/page/src/types/music.ts @@ -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[]; +} diff --git a/page/src/types/next-auth.d.ts b/page/src/types/next-auth.d.ts new file mode 100644 index 0000000..df6aa0a --- /dev/null +++ b/page/src/types/next-auth.d.ts @@ -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; + } +} diff --git a/page/tsconfig.json b/page/tsconfig.json index cf9c65d..75d74a7 100644 --- a/page/tsconfig.json +++ b/page/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2020", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true,