diff --git a/app/api/chzzk/callback/route.ts b/app/api/chzzk/callback/route.ts index 22209d3..0b68488 100644 --- a/app/api/chzzk/callback/route.ts +++ b/app/api/chzzk/callback/route.ts @@ -108,12 +108,13 @@ export async function GET(request: Request) { res.cookies.set(AUTH_COOKIE, serializeAuthSession(session), authCookieOptions()); res.cookies.delete(OAUTH_STATE_COOKIE); return res; - } catch (e) { + } catch { if (accessToken) await revokeToken(accessToken, 'access_token'); if (refreshToken) await revokeToken(refreshToken, 'refresh_token'); - const message = - e instanceof Error ? e.message : '치지직 로그인에 실패했습니다.'; - const res = NextResponse.redirect(homeRedirect(origin, message)); + // 외부 오류 원문에는 계정 식별자 등이 포함될 수 있어 고정 문구만 전달한다. + const res = NextResponse.redirect( + homeRedirect(origin, '치지직 로그인에 실패했습니다. 다시 시도해 주세요.'), + ); res.cookies.delete(OAUTH_STATE_COOKIE); return res; } diff --git a/app/api/rooms/join/route.ts b/app/api/rooms/join/route.ts index 45dfb65..3e8643a 100644 --- a/app/api/rooms/join/route.ts +++ b/app/api/rooms/join/route.ts @@ -103,14 +103,11 @@ export async function POST(request: Request) { if (room.settings.chzzkEnabled) { try { await ensureRoomChat(room.id); - } catch (e) { + } catch { if (created) { return NextResponse.json( { - error: - e instanceof Error - ? e.message - : '채팅 연결에 실패했습니다. 방송 중인지 확인해 주세요.', + error: '채팅 연결에 실패했습니다. 방송 중인지 확인해 주세요.', room: toPublicRoom(room), created, }, diff --git a/app/page.tsx b/app/page.tsx index d766b1c..67932b5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -970,8 +970,8 @@ export default function Home() { className="text-white/60 underline decoration-white/25 underline-offset-2 hover:text-white/85" > 연락: kimyu@kimyu.xyz - - + + diff --git a/lib/chzzk/channel.ts b/lib/chzzk/channel.ts index 082966e..015a4e1 100644 --- a/lib/chzzk/channel.ts +++ b/lib/chzzk/channel.ts @@ -120,7 +120,8 @@ export async function fetchChatAccessToken(chatChannelId: string): Promise 0) { - return `${kind}:${String(id)}`; + return hashMessageKey(`${kind}:${String(id)}`); } const time = messageTimeMs(chat); const profile = parseMaybeJson<{ nickname?: string; userIdHash?: string }>( @@ -109,9 +110,16 @@ function messageKey( const msg = String(chat.msg ?? chat.content ?? ''); if (time == null && !who && !msg) return null; if (kind === 'donation') { - return `donation:${time ?? 0}:${who}:${Number(extras?.payAmount ?? 0)}:${msg}`; + return hashMessageKey( + `donation:${time ?? 0}:${who}:${Number(extras?.payAmount ?? 0)}:${msg}`, + ); } - return `chat:${time ?? 0}:${who}:${msg}`; + return hashMessageKey(`chat:${time ?? 0}:${who}:${msg}`); +} + +/** 중복 검사용 키에는 사용자 해시·닉네임·메시지 원문을 보관하지 않는다. */ +function hashMessageKey(value: string): string { + return createHash('sha256').update(value).digest('base64url'); } function rememberMessageKey(handle: SessionHandle, key: string | null): boolean { @@ -475,11 +483,12 @@ export async function connectRoomChat(roomId: string) { setRoomMeta(roomId, { channelName: info.channelName, chatStatus: 'connecting' }); notifyRoom(roomId); await openSocket(roomId, channelId); - } catch (e) { - const message = e instanceof Error ? e.message : '채팅 연결 실패'; + } catch { + // 외부 라이브러리 오류에는 URL·토큰·식별자가 섞일 수 있어 원문을 보관하지 않는다. + const message = '채팅 연결에 실패했습니다. 방송 상태를 확인해 주세요.'; setRoomMeta(roomId, { chatStatus: 'error', chatError: message }); notifyRoom(roomId); - throw e instanceof Error ? e : new Error(message); + throw new Error(message); } } diff --git a/lib/chzzk/oauth.ts b/lib/chzzk/oauth.ts index d9b30f4..d37a96d 100644 --- a/lib/chzzk/oauth.ts +++ b/lib/chzzk/oauth.ts @@ -32,11 +32,11 @@ export async function exchangeAuthorizationCode(code: string, state: string) { tokenType?: string; expiresIn?: string | number; }; - message?: string; code?: number; }; if (!res.ok || !json.content?.accessToken) { - throw new Error(json.message || `토큰 발급 실패 (${res.status})`); + // 외부 API 원문은 계정 정보 등이 섞일 수 있으므로 전달·기록하지 않는다. + throw new Error(`치지직 인증 처리 실패 (${res.status})`); } return { accessToken: json.content.accessToken, @@ -54,10 +54,9 @@ export async function fetchAuthedUser(accessToken: string) { }); const json = (await res.json().catch(() => ({}))) as { content?: { channelId?: string; channelName?: string }; - message?: string; }; if (!res.ok || !json.content?.channelId) { - throw new Error(json.message || `유저 정보 조회 실패 (${res.status})`); + throw new Error(`치지직 사용자 확인 실패 (${res.status})`); } return { channelId: json.content.channelId.toLowerCase(), diff --git a/next.config.ts b/next.config.ts index 35e483c..9c1e4dd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,11 @@ import { fileURLToPath } from "url"; const projectRoot = path.dirname(fileURLToPath(import.meta.url)); const nextConfig: NextConfig = { + // 개발 서버에서도 OAuth code/state가 포함된 요청 URL을 콘솔에 남기지 않음. + // 운영 서버는 별도 access log를 사용하지 않으며, 앱에서도 요청 본문을 기록하지 않는다. + logging: { + incomingRequests: false, + }, // 홈 디렉터리 package-lock.json 오인 방지 (외부 도메인/IP 접속 시에도 안정) turbopack: { root: projectRoot,