치지직 앱으로 연동하고, 연동 확인된 ID만 접속할 수 있도록 수정, 개발용으로는 채널ID를 수동으로 접속할 수 있도록 변경.
최초 추천 고정 배치 추가.
This commit is contained in:
16
app/api/auth/logout/route.ts
Normal file
16
app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { AUTH_COOKIE } from '@/lib/auth/session';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST() {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(AUTH_COOKIE, '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
19
app/api/auth/session/route.ts
Normal file
19
app/api/auth/session/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { authModePublic } from '@/lib/auth/room-access';
|
||||
import { readAuthSession } from '@/lib/auth/session';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
const session = await readAuthSession();
|
||||
return NextResponse.json({
|
||||
...authModePublic(),
|
||||
session: session
|
||||
? {
|
||||
channelId: session.channelId,
|
||||
channelName: session.channelName,
|
||||
exp: session.exp,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
112
app/api/chzzk/callback/route.ts
Normal file
112
app/api/chzzk/callback/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
app/api/chzzk/login/route.ts
Normal file
44
app/api/chzzk/login/route.ts
Normal 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;
|
||||
}
|
||||
31
app/api/rooms/[roomId]/add-dice/route.ts
Normal file
31
app/api/rooms/[roomId]/add-dice/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
|
||||
import { addDice, getRoom, notifyRoom, toPublicRoom } from '@/lib/rooms/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
type Params = { params: Promise<{ roomId: string }> };
|
||||
|
||||
export async function POST(request: Request, { params }: Params) {
|
||||
const { roomId } = await params;
|
||||
const room = getRoom(roomId);
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
|
||||
}
|
||||
|
||||
const access = await assertChannelRoomAccess(room, request);
|
||||
if (!access.ok) return access.response;
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { count?: number };
|
||||
const count = Number(body.count ?? 1);
|
||||
if (!Number.isFinite(count) || count < 1) {
|
||||
return NextResponse.json({ error: 'count는 1 이상이어야 합니다.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = addDice(roomId, count);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: '처리 실패' }, { status: 500 });
|
||||
}
|
||||
notifyRoom(roomId);
|
||||
return NextResponse.json({ room: toPublicRoom(updated) });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
|
||||
import {
|
||||
consumeDice,
|
||||
getRoom,
|
||||
@@ -17,6 +18,9 @@ export async function POST(request: Request, { params }: Params) {
|
||||
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
|
||||
}
|
||||
|
||||
const access = await assertChannelRoomAccess(room, request);
|
||||
if (!access.ok) return access.response;
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { count?: number };
|
||||
const count = Math.max(1, Math.trunc(body.count ?? 1));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
cancelScheduledDisconnectRoomChat,
|
||||
scheduleDisconnectRoomChat,
|
||||
} from '@/lib/chzzk/chat-session';
|
||||
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
|
||||
import {
|
||||
addRoomViewer,
|
||||
getRoom,
|
||||
@@ -26,6 +27,9 @@ export async function GET(request: Request, { params }: Params) {
|
||||
});
|
||||
}
|
||||
|
||||
const access = await assertChannelRoomAccess(room, request);
|
||||
if (!access.ok) return access.response;
|
||||
|
||||
const clientId =
|
||||
new URL(request.url).searchParams.get('clientId')?.trim() ||
|
||||
crypto.randomUUID();
|
||||
@@ -40,14 +44,12 @@ export async function GET(request: Request, { params }: Params) {
|
||||
presenceHeld = false;
|
||||
const { emptied } = removeRoomViewer(roomId, clientId);
|
||||
if (emptied) {
|
||||
// 즉시 끊지 않음 — 빠른 재입장과 경쟁하지 않도록 유예
|
||||
scheduleDisconnectRoomChat(roomId);
|
||||
}
|
||||
};
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// 재입장 시 예약된 disconnect 취소
|
||||
cancelScheduledDisconnectRoomChat(roomId);
|
||||
addRoomViewer(roomId, clientId);
|
||||
presenceHeld = true;
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
|
||||
import { getRoom, toPublicRoom } from '@/lib/rooms/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
type Params = { params: Promise<{ roomId: string }> };
|
||||
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
export async function GET(request: Request, { params }: Params) {
|
||||
const { roomId } = await params;
|
||||
const room = getRoom(roomId);
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
|
||||
}
|
||||
const access = await assertChannelRoomAccess(room, request);
|
||||
if (!access.ok) return access.response;
|
||||
return NextResponse.json({ room: toPublicRoom(room) });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
|
||||
import {
|
||||
addDiceFromDonation,
|
||||
getRoom,
|
||||
@@ -17,6 +18,9 @@ export async function POST(request: Request, { params }: Params) {
|
||||
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
|
||||
}
|
||||
|
||||
const access = await assertChannelRoomAccess(room, request);
|
||||
if (!access.ok) return access.response;
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
payAmount?: number;
|
||||
donatorNickname?: string;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { parseChzzkChannelId } from '@/lib/chzzk/channel';
|
||||
import {
|
||||
allowChannelIdJoin,
|
||||
isChzzkOAuthConfigured,
|
||||
requireChzzkLoginForChannelRooms,
|
||||
} from '@/lib/auth/mode';
|
||||
import { getRequestAuthSession } from '@/lib/auth/room-access';
|
||||
import {
|
||||
cancelScheduledDisconnectRoomChat,
|
||||
ensureRoomChat,
|
||||
@@ -15,7 +21,8 @@ export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* 치지직 채널 ID(또는 라이브 URL)로 룸 입장.
|
||||
* 없으면 전달된 치즈 설정으로 새 룸을 만들고 채팅 연결을 시도한다.
|
||||
* - 개발: 채널 ID 직접 입장 가능 (+ 로그인 세션 있으면 본인 채널도 가능)
|
||||
* - 릴리즈: 로그인 세션의 채널 ID와 일치할 때만 입장
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
@@ -25,8 +32,39 @@ export async function POST(request: Request) {
|
||||
maxMultiplier?: number;
|
||||
};
|
||||
|
||||
const session = await getRequestAuthSession(request);
|
||||
const raw = body.channelIdOrUrl || '';
|
||||
const channelId = parseChzzkChannelId(raw);
|
||||
let channelId = parseChzzkChannelId(raw);
|
||||
|
||||
if (requireChzzkLoginForChannelRooms()) {
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'릴리즈 환경에서는 치지직 로그인 후 본인 채널로만 입장할 수 있습니다.',
|
||||
code: 'CHZZK_AUTH_REQUIRED',
|
||||
allowChannelIdJoin: allowChannelIdJoin(),
|
||||
oauthConfigured: isChzzkOAuthConfigured(),
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
// 입력된 채널이 있으면 세션과 일치해야 함. 없으면 세션 채널 사용.
|
||||
if (channelId && channelId !== session.channelId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '로그인한 채널과 다른 채널 ID로는 입장할 수 없습니다.',
|
||||
code: 'CHANNEL_MISMATCH',
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
channelId = session.channelId;
|
||||
} else if (!channelId) {
|
||||
// 개발: 세션만 있으면 세션 채널로 입장 가능
|
||||
if (session?.channelId) channelId = session.channelId;
|
||||
}
|
||||
|
||||
if (!channelId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -44,7 +82,7 @@ export async function POST(request: Request) {
|
||||
created = true;
|
||||
room = createRoom({
|
||||
chzzkEnabled: true,
|
||||
liveUrl: raw.trim(),
|
||||
liveUrl: raw.trim() || `https://chzzk.naver.com/live/${channelId}`,
|
||||
channelId,
|
||||
cheesePerDice: body.cheesePerDice,
|
||||
multiplyByCheese: body.multiplyByCheese,
|
||||
@@ -52,7 +90,6 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
// 이전 세션 leave가 늦게 와도 끊기지 않게 예약 해제 + 실제 WS 생존 여부 확인
|
||||
cancelScheduledDisconnectRoomChat(room.id);
|
||||
|
||||
if (room.settings.chzzkEnabled) {
|
||||
|
||||
Reference in New Issue
Block a user