치지직 앱으로 연동하고, 연동 확인된 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

46
lib/auth/mode.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* 개발: 채널 ID 입장 + 치지직 로그인 모두 허용
* 릴리즈(production): 치지직 로그인(세션)으로만 채널 룸 입장
*
* 강제 오버라이드:
* - DRINKMARBLE_ALLOW_CHANNEL_ID_JOIN=true → production에서도 채널 ID 입장 허용
* - DRINKMARBLE_REQUIRE_CHZZK_LOGIN=true → development에서도 로그인 필수
*/
export function allowChannelIdJoin(): boolean {
if (process.env.DRINKMARBLE_REQUIRE_CHZZK_LOGIN === 'true') return false;
if (process.env.DRINKMARBLE_ALLOW_CHANNEL_ID_JOIN === 'true') return true;
return process.env.NODE_ENV !== 'production';
}
export function requireChzzkLoginForChannelRooms(): boolean {
return !allowChannelIdJoin();
}
export function isChzzkOAuthConfigured(): boolean {
return Boolean(
process.env.CHZZK_CLIENT_ID?.trim() &&
process.env.CHZZK_CLIENT_SECRET?.trim() &&
process.env.CHZZK_REDIRECT_URI?.trim(),
);
}
export function getChzzkOAuthConfig() {
const clientId = process.env.CHZZK_CLIENT_ID?.trim() || '';
const clientSecret = process.env.CHZZK_CLIENT_SECRET?.trim() || '';
const redirectUri = process.env.CHZZK_REDIRECT_URI?.trim() || '';
if (!clientId || !clientSecret || !redirectUri) {
throw new Error(
'치지직 OAuth 환경변수(CHZZK_CLIENT_ID, CHZZK_CLIENT_SECRET, CHZZK_REDIRECT_URI)가 필요합니다.',
);
}
return { clientId, clientSecret, redirectUri };
}
export function getAuthSessionSecret(): string {
const explicit = process.env.AUTH_SESSION_SECRET?.trim();
if (explicit) return explicit;
const fallback = process.env.CHZZK_CLIENT_SECRET?.trim();
if (fallback) return fallback;
// 개발 전용 폴백 (production에서는 OAuth 설정이 있어야 함)
return 'drinkmarble-dev-session-secret';
}

59
lib/auth/public-origin.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* Caddy 등 리버스 프록시 뒤에서 request.url 이
* http://0.0.0.0:13000 처럼 잡히는 경우가 있어, 공개 Origin 을 따로 계산한다.
*/
export function getPublicOrigin(request: Request): string {
const fromEnv =
process.env.APP_ORIGIN?.trim() ||
process.env.NEXT_PUBLIC_APP_ORIGIN?.trim();
if (fromEnv) {
try {
return new URL(fromEnv).origin;
} catch {
// fall through
}
}
const redirectUri = process.env.CHZZK_REDIRECT_URI?.trim();
if (redirectUri) {
try {
return new URL(redirectUri).origin;
} catch {
// fall through
}
}
const xfHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim();
const xfProto =
request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() || 'https';
if (xfHost && !isBadHost(xfHost)) {
return `${xfProto}://${xfHost}`;
}
const host = request.headers.get('host')?.trim();
if (host && !isBadHost(host)) {
const proto =
request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() ||
(host.includes('localhost') || host.startsWith('127.') ? 'http' : 'https');
return `${proto}://${host}`;
}
try {
const origin = new URL(request.url).origin;
if (!isBadHost(new URL(origin).host)) return origin;
} catch {
// fall through
}
return 'https://drink.kimyu.xyz';
}
function isBadHost(host: string) {
const h = host.toLowerCase().split(':')[0];
return (
h === '0.0.0.0' ||
h === '::' ||
h === '[::]' ||
h === 'host.docker.internal'
);
}

68
lib/auth/room-access.ts Normal file
View File

@@ -0,0 +1,68 @@
import { NextResponse } from 'next/server';
import {
allowChannelIdJoin,
isChzzkOAuthConfigured,
requireChzzkLoginForChannelRooms,
} from '@/lib/auth/mode';
import { readAuthSession, type AuthSession } from '@/lib/auth/session';
import type { RoomRecord } from '@/lib/rooms/store';
export async function getRequestAuthSession(
request?: Request,
): Promise<AuthSession | null> {
// Route Handler에서는 cookies()가 기본. request Cookie 헤더도 보조 파싱.
const fromCookies = await readAuthSession();
if (fromCookies) return fromCookies;
if (!request) return null;
const header = request.headers.get('cookie') || '';
const match = /(?:^|;\s*)drinkmarble_auth=([^;]+)/.exec(header);
if (!match?.[1]) return null;
const { parseAuthSession } = await import('@/lib/auth/session');
return parseAuthSession(decodeURIComponent(match[1]));
}
/**
* 치지직 연동 룸 접근 검사.
* - 개발(채널 ID 허용): 세션 없어도 OK
* - 릴리즈: 세션의 channelId가 룸 채널과 일치해야 함
*/
export async function assertChannelRoomAccess(
room: RoomRecord,
request?: Request,
): Promise<{ ok: true; session: AuthSession | null } | { ok: false; response: NextResponse }> {
if (!room.settings.chzzkEnabled) {
return { ok: true, session: null };
}
if (!requireChzzkLoginForChannelRooms()) {
return { ok: true, session: await getRequestAuthSession(request) };
}
const session = await getRequestAuthSession(request);
const roomChannel = room.settings.channelId?.toLowerCase() || '';
if (!session || !roomChannel || session.channelId !== roomChannel) {
return {
ok: false,
response: NextResponse.json(
{
error:
'릴리즈 환경에서는 치지직 로그인으로 본인 채널만 입장할 수 있습니다. 홈에서 다시 로그인해 주세요.',
code: 'CHZZK_AUTH_REQUIRED',
allowChannelIdJoin: allowChannelIdJoin(),
oauthConfigured: isChzzkOAuthConfigured(),
},
{ status: 401 },
),
};
}
return { ok: true, session };
}
export function authModePublic() {
return {
allowChannelIdJoin: allowChannelIdJoin(),
requireChzzkLogin: requireChzzkLoginForChannelRooms(),
oauthConfigured: isChzzkOAuthConfigured(),
nodeEnv: process.env.NODE_ENV,
};
}

137
lib/auth/session.ts Normal file
View File

@@ -0,0 +1,137 @@
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
import { cookies } from 'next/headers';
import { getAuthSessionSecret } from './mode';
export const AUTH_COOKIE = 'drinkmarble_auth';
export const OAUTH_STATE_COOKIE = 'drinkmarble_oauth_state';
export type AuthSession = {
channelId: string;
channelName: string | null;
/** unix ms */
exp: number;
};
export type OAuthStatePayload = {
state: string;
cheesePerDice?: number;
multiplyByCheese?: boolean;
maxMultiplier?: number;
/** unix ms */
exp: number;
};
function b64urlEncode(buf: Buffer | string) {
const b = typeof buf === 'string' ? Buffer.from(buf, 'utf8') : buf;
return b
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function b64urlDecode(s: string) {
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
return Buffer.from(b64, 'base64');
}
function sign(payloadB64: string) {
return createHmac('sha256', getAuthSessionSecret())
.update(payloadB64)
.digest('base64url');
}
function seal<T extends object>(data: T): string {
const payloadB64 = b64urlEncode(JSON.stringify(data));
return `${payloadB64}.${sign(payloadB64)}`;
}
function unseal<T extends object>(token: string): T | null {
const parts = token.split('.');
if (parts.length !== 2) return null;
const [payloadB64, sig] = parts;
if (!payloadB64 || !sig) return null;
const expected = sign(payloadB64);
try {
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
} catch {
return null;
}
try {
return JSON.parse(b64urlDecode(payloadB64).toString('utf8')) as T;
} catch {
return null;
}
}
/** 브라우저 종료 시까지 + 최대 12시간 (치지직 토큰은 저장하지 않음) */
const SESSION_MAX_MS = 12 * 60 * 60 * 1000;
export function createAuthSession(
channelId: string,
channelName: string | null,
): AuthSession {
return {
channelId: channelId.trim().toLowerCase(),
channelName,
exp: Date.now() + SESSION_MAX_MS,
};
}
export function serializeAuthSession(session: AuthSession) {
return seal(session);
}
export function parseAuthSession(token: string | undefined | null): AuthSession | null {
if (!token) return null;
const data = unseal<AuthSession>(token);
if (!data?.channelId || !data.exp) return null;
if (Date.now() > data.exp) return null;
return {
channelId: String(data.channelId).toLowerCase(),
channelName: data.channelName ?? null,
exp: data.exp,
};
}
export async function readAuthSession(): Promise<AuthSession | null> {
const jar = await cookies();
return parseAuthSession(jar.get(AUTH_COOKIE)?.value);
}
export function authCookieOptions(maxAgeSec = Math.floor(SESSION_MAX_MS / 1000)) {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: maxAgeSec,
};
}
export function createOAuthState(input: {
cheesePerDice?: number;
multiplyByCheese?: boolean;
maxMultiplier?: number;
}) {
const state = b64urlEncode(randomBytes(24));
const payload: OAuthStatePayload = {
state,
cheesePerDice: input.cheesePerDice,
multiplyByCheese: input.multiplyByCheese,
maxMultiplier: input.maxMultiplier,
exp: Date.now() + 10 * 60 * 1000,
};
return { state, sealed: seal(payload) };
}
export function parseOAuthStateCookie(token: string | undefined | null): OAuthStatePayload | null {
if (!token) return null;
const data = unseal<OAuthStatePayload>(token);
if (!data?.state || !data.exp) return null;
if (Date.now() > data.exp) return null;
return data;
}

89
lib/chzzk/oauth.ts Normal file
View File

@@ -0,0 +1,89 @@
import { getChzzkOAuthConfig } from '@/lib/auth/mode';
const OPENAPI = 'https://openapi.chzzk.naver.com';
export function buildChzzkLoginUrl(state: string) {
const { clientId, redirectUri } = getChzzkOAuthConfig();
const url = new URL('https://chzzk.naver.com/account-interlock');
url.searchParams.set('clientId', clientId);
url.searchParams.set('redirectUri', redirectUri);
url.searchParams.set('state', state);
return url.toString();
}
export async function exchangeAuthorizationCode(code: string, state: string) {
const { clientId, clientSecret } = getChzzkOAuthConfig();
const res = await fetch(`${OPENAPI}/auth/v1/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grantType: 'authorization_code',
clientId,
clientSecret,
code,
state,
}),
cache: 'no-store',
});
const json = (await res.json().catch(() => ({}))) as {
content?: {
accessToken?: string;
refreshToken?: string;
tokenType?: string;
expiresIn?: string | number;
};
message?: string;
code?: number;
};
if (!res.ok || !json.content?.accessToken) {
throw new Error(json.message || `토큰 발급 실패 (${res.status})`);
}
return {
accessToken: json.content.accessToken,
refreshToken: json.content.refreshToken ?? null,
};
}
export async function fetchAuthedUser(accessToken: string) {
const res = await fetch(`${OPENAPI}/open/v1/users/me`, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
cache: 'no-store',
});
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})`);
}
return {
channelId: json.content.channelId.toLowerCase(),
channelName: json.content.channelName ?? null,
};
}
/** 확인 후 치지직 토큰 폐기 (저장하지 않음) */
export async function revokeToken(
token: string,
tokenTypeHint: 'access_token' | 'refresh_token' = 'access_token',
) {
const { clientId, clientSecret } = getChzzkOAuthConfig();
try {
await fetch(`${OPENAPI}/auth/v1/token/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId,
clientSecret,
token,
tokenTypeHint,
}),
cache: 'no-store',
});
} catch {
// 폐기는 best-effort
}
}

View File

@@ -48,28 +48,38 @@ export function useRoom(roomId: string | null) {
? crypto.randomUUID()
: `c-${Date.now()}-${Math.random().toString(16).slice(2)}`;
let es: EventSource | null = null;
let cancelled = false;
void fetch(`/api/rooms/${roomId}`)
.then((r) => r.json())
.then((j) => {
.then(async (r) => {
const j = await r.json();
if (cancelled) return;
if (!r.ok) {
setError(j.error || '방을 불러오지 못했습니다.');
return;
}
if (j.room) setRoom(j.room as PublicRoomState);
else setError(j.error || '방을 불러오지 못했습니다.');
})
.catch(() => setError('방을 불러오지 못했습니다.'));
const es = new EventSource(
`/api/rooms/${roomId}/events?clientId=${encodeURIComponent(clientId)}`,
);
es.addEventListener('room', (ev) => {
try {
setRoom(JSON.parse((ev as MessageEvent).data) as PublicRoomState);
setError(null);
} catch {
// ignore
}
});
es.onerror = () => {
// keep last state; browser will retry SSE
};
es = new EventSource(
`/api/rooms/${roomId}/events?clientId=${encodeURIComponent(clientId)}`,
);
es.addEventListener('room', (ev) => {
try {
setRoom(JSON.parse((ev as MessageEvent).data) as PublicRoomState);
setError(null);
} catch {
// ignore
}
});
es.onerror = () => {
// keep last state; browser will retry SSE
};
})
.catch(() => {
if (!cancelled) setError('방을 불러오지 못했습니다.');
});
const leave = () => {
try {
@@ -94,8 +104,9 @@ export function useRoom(roomId: string | null) {
window.addEventListener('pagehide', leave);
return () => {
cancelled = true;
window.removeEventListener('pagehide', leave);
es.close();
es?.close();
leave();
};
}, [roomId]);
@@ -136,5 +147,26 @@ export function useRoom(roomId: string | null) {
[roomId],
);
return { room, error, consumeDice, simulateDonation };
const addDice = useCallback(
async (count = 1) => {
if (!roomId) return { ok: false as const, error: '방이 없습니다.' };
const res = await fetch(`/api/rooms/${roomId}/add-dice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count }),
});
const json = await res.json();
if (!res.ok) {
return {
ok: false as const,
error: (json.error as string) || '주사위를 추가할 수 없습니다.',
};
}
setRoom(json.room as PublicRoomState);
return { ok: true as const };
},
[roomId],
);
return { room, error, consumeDice, simulateDonation, addDice };
}

View File

@@ -261,6 +261,15 @@ export function consumeDice(roomId: string, count = 1) {
return room;
}
/** 후원 없이 주사위 큐만 수동으로 추가 */
export function addDice(roomId: string, count = 1) {
const room = getRoom(roomId);
if (!room) return null;
const n = Math.max(1, Math.trunc(count));
room.diceQueue += n;
return room;
}
export function toPublicRoom(room: RoomRecord): PublicRoomState {
return {
id: room.id,

9
lib/ui/debug.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* 디버그용 오버레이(룸 상태, 후원 시뮬, 진행 방향 등) 표시 여부.
* 릴리즈(production)에서는 기본으로 숨기고, 주사위 Roll·결과만 남긴다.
*
* NEXT_PUBLIC_DRINKMARBLE_SHOW_DEBUG=true 로 릴리즈에서도 강제로 켤 수 있다.
*/
export const SHOW_DEBUG_UI =
process.env.NEXT_PUBLIC_DRINKMARBLE_SHOW_DEBUG === 'true' ||
process.env.NODE_ENV !== 'production';