치지직 앱으로 연동하고, 연동 확인된 ID만 접속할 수 있도록 수정, 개발용으로는 채널ID를 수동으로 접속할 수 있도록 변경.

최초 추천 고정 배치 추가.
This commit is contained in:
kimyu
2026-09-18 04:09:31 +09:00
parent 6f72824598
commit 73520f9f06
26 changed files with 1272 additions and 162 deletions

View File

@@ -0,0 +1,112 @@
import { NextResponse } from 'next/server';
import { getPublicOrigin } from '@/lib/auth/public-origin';
import {
AUTH_COOKIE,
OAUTH_STATE_COOKIE,
authCookieOptions,
createAuthSession,
parseOAuthStateCookie,
serializeAuthSession,
} from '@/lib/auth/session';
import {
exchangeAuthorizationCode,
fetchAuthedUser,
revokeToken,
} from '@/lib/chzzk/oauth';
import {
cancelScheduledDisconnectRoomChat,
ensureRoomChat,
} from '@/lib/chzzk/chat-session';
import {
createRoom,
findRoomByChannelId,
getRoom,
} from '@/lib/rooms/store';
export const runtime = 'nodejs';
function homeRedirect(origin: string, error?: string) {
const url = new URL('/', origin);
if (error) url.searchParams.set('authError', error);
else url.searchParams.set('auth', 'ok');
return url;
}
/**
* 치지직 OAuth 콜백.
* 채널 ID 확인 → 우리 세션 쿠키만 저장 → 치지직 토큰 폐기 → 룸 입장
*/
export async function GET(request: Request) {
const url = new URL(request.url);
const origin = getPublicOrigin(request);
const code = url.searchParams.get('code') || '';
const state = url.searchParams.get('state') || '';
const stateCookie = request.headers
.get('cookie')
?.match(/(?:^|;\s*)drinkmarble_oauth_state=([^;]+)/)?.[1];
const decodedState = stateCookie
? parseOAuthStateCookie(decodeURIComponent(stateCookie))
: null;
if (!code || !state || !decodedState || decodedState.state !== state) {
const res = NextResponse.redirect(
homeRedirect(origin, '로그인 검증에 실패했습니다. 다시 시도해 주세요.'),
);
res.cookies.delete(OAUTH_STATE_COOKIE);
return res;
}
let accessToken: string | null = null;
let refreshToken: string | null = null;
try {
const tokens = await exchangeAuthorizationCode(code, state);
accessToken = tokens.accessToken;
refreshToken = tokens.refreshToken;
const user = await fetchAuthedUser(accessToken);
const session = createAuthSession(user.channelId, user.channelName);
// 치지직 토큰은 저장하지 않고 즉시 폐기
await revokeToken(accessToken, 'access_token');
if (refreshToken) await revokeToken(refreshToken, 'refresh_token');
accessToken = null;
refreshToken = null;
let room = findRoomByChannelId(user.channelId);
if (!room) {
room = createRoom({
chzzkEnabled: true,
liveUrl: `https://chzzk.naver.com/live/${user.channelId}`,
channelId: user.channelId,
cheesePerDice: decodedState.cheesePerDice,
multiplyByCheese: decodedState.multiplyByCheese,
maxMultiplier: decodedState.maxMultiplier,
});
}
cancelScheduledDisconnectRoomChat(room.id);
try {
await ensureRoomChat(room.id);
} catch {
// 방송 중이 아니어도 세션·룸은 유지하고 게임으로 보냄
}
// room.id = 서버 룸 UUID (게임 URL용)
// user.channelId = 치지직 채널 (세션·room.settings.channelId에 보관)
const roomId = getRoom(room.id)?.id ?? room.id;
const res = NextResponse.redirect(new URL(`/game?roomId=${roomId}`, origin));
res.cookies.set(AUTH_COOKIE, serializeAuthSession(session), authCookieOptions());
res.cookies.delete(OAUTH_STATE_COOKIE);
return res;
} catch (e) {
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));
res.cookies.delete(OAUTH_STATE_COOKIE);
return res;
}
}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { isChzzkOAuthConfigured } from '@/lib/auth/mode';
import {
createOAuthState,
OAUTH_STATE_COOKIE,
authCookieOptions,
} from '@/lib/auth/session';
import { buildChzzkLoginUrl } from '@/lib/chzzk/oauth';
export const runtime = 'nodejs';
/**
* 치지직 로그인 시작.
* 쿼리로 치즈 설정을 넘기면 콜백에서 바로 룸 입장에 사용한다.
*/
export async function GET(request: Request) {
if (!isChzzkOAuthConfigured()) {
return NextResponse.json(
{
error:
'치지직 OAuth가 설정되지 않았습니다. CHZZK_CLIENT_ID / CHZZK_CLIENT_SECRET / CHZZK_REDIRECT_URI 를 확인해 주세요.',
},
{ status: 503 },
);
}
const url = new URL(request.url);
const cheesePerDice = Number(url.searchParams.get('cheesePerDice') || 1000);
const multiplyByCheese = url.searchParams.get('multiplyByCheese') !== '0';
const maxMultiplier = Number(url.searchParams.get('maxMultiplier') || 0);
const { state, sealed } = createOAuthState({
cheesePerDice: Number.isFinite(cheesePerDice) ? cheesePerDice : 1000,
multiplyByCheese,
maxMultiplier: Number.isFinite(maxMultiplier) ? maxMultiplier : 0,
});
const res = NextResponse.redirect(buildChzzkLoginUrl(state));
res.cookies.set(OAUTH_STATE_COOKIE, sealed, {
...authCookieOptions(600),
maxAge: 600,
});
return res;
}