/** HTTP API (docs/05 §6, docs/06). */ import { Hono, type Context } from 'hono'; import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; import { z } from 'zod'; import { nicknameError, normalizeNickname, normalizeRoomCode, formatRoomCode } from '@bg/shared'; import type { Config } from '../config'; import { authorizeUrl, avatarUrl, exchangeCode, safeNext, type DiscordUser } from '../auth/discord'; import { SESSION_COOKIE, SESSION_TTL, randomToken, signValue, unsignValue, type Sessions } from '../auth/sessions'; import { toPublicUser, type Users } from '../auth/users'; import type { RoomManager } from '../rooms/manager'; import { WindowLimiter } from '../ws/rate-limit'; import { createAdminApp, type AdminDeps } from './admin'; import type { Roles } from '../admin/roles'; import type { SettingsStore } from '../admin/settings'; export interface HttpDeps { config: Config; users: Users; sessions: Sessions; rooms: RoomManager; /** Injected for tests. */ discordExchange?: (code: string, redirectUri: string) => Promise; ipOf: (req: Request) => string; health: () => { ok: boolean; [k: string]: unknown }; roles: Roles; settings: SettingsStore; admin: Omit; } type Env = { Variables: { userId: string | null } }; const OAUTH_COOKIE = 'oauth'; export function createHttpApp(d: HttpDeps) { const app = new Hono(); const createLimiter = new WindowLimiter(10, 60_000); const lookupLimiter = new WindowLimiter(30, 60_000); const guestLimiter = new WindowLimiter(20, 60_000); const redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`; const cookieOpts = (maxAgeMs: number) => ({ httpOnly: true, secure: d.config.secureCookies, sameSite: 'Lax' as const, path: '/', maxAge: Math.floor(maxAgeMs / 1000), }); // Session resolution. app.use('/api/*', async (c, next) => { const token = getCookie(c, SESSION_COOKIE); c.set('userId', token ? d.sessions.resolve(token) : null); await next(); }); // CSRF: state-changing requests must come from our origin (docs/04 §7). app.use('/api/*', async (c, next) => { if (c.req.method !== 'GET' && c.req.method !== 'HEAD') { const origin = c.req.header('origin'); if (!origin || !d.config.allowedOrigins.includes(origin)) return c.json({ error: '잘못된 요청이에요.' }, 403); } await next(); }); const requireUser = (c: Context) => c.get('userId'); const startSession = (c: Context, userId: string, kind: 'guest' | 'member') => { const { token } = d.sessions.create(userId, kind, c.req.header('user-agent') ?? null); setCookie(c, SESSION_COOKIE, token, cookieOpts(SESSION_TTL[kind])); }; app.get('/healthz', (c) => { const h = d.health(); return c.json(h, h.ok ? 200 : 503); }); app.get('/api/config', (c) => { const site = d.settings.getSite(); return c.json({ discord: d.config.discord !== null, catalog: d.settings.catalog(), siteName: site.siteName, announcement: site.announcement, maintenance: site.maintenance, }); }); app.post('/api/auth/guest', async (c) => { if (!guestLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429); const body = z.object({ nickname: z.string().max(100) }).safeParse(await c.req.json().catch(() => null)); if (!body.success) return c.json({ error: '닉네임을 입력해 주세요.' }, 400); const nick = normalizeNickname(body.data.nickname); const e = nicknameError(nick, d.settings.getSite().bannedWords); if (e) return c.json({ error: e }, 400); const current = requireUser(c); if (current) { // Already signed in: treat as a nickname change instead of creating another account. const err = d.users.changeNickname(current, nick); if (err) return c.json({ error: err }, 400); return c.json({ me: toPublicUser(d.users.get(current)!) }); } const u = d.users.createGuest(nick); startSession(c, u.id, 'guest'); return c.json({ me: toPublicUser(u) }); }); app.get('/api/auth/discord/start', async (c) => { if (!d.config.discord) return c.text('디스코드 로그인이 설정되지 않았어요.', 404); const state = randomToken(24); const next = safeNext(c.req.query('next')); setCookie(c, OAUTH_COOKIE, await signValue(d.config.sessionSecret, JSON.stringify({ state, next, at: Date.now() })), { ...cookieOpts(10 * 60_000), path: '/api/auth/discord', }); return c.redirect(authorizeUrl(d.config.discord.clientId, redirectUri, state)); }); app.get('/api/auth/discord/callback', async (c) => { const discord = d.config.discord; if (!discord) return c.text('디스코드 로그인이 설정되지 않았어요.', 404); const raw = getCookie(c, OAUTH_COOKIE); deleteCookie(c, OAUTH_COOKIE, { path: '/api/auth/discord' }); const payload = raw ? await unsignValue(d.config.sessionSecret, raw) : null; const parsed = payload ? (JSON.parse(payload) as { state: string; next: string; at: number }) : null; const code = c.req.query('code'); if (!parsed || !code || parsed.state !== c.req.query('state') || Date.now() - parsed.at > 10 * 60_000) { return c.redirect('/?login=failed'); } let du: DiscordUser; try { du = d.discordExchange ? await d.discordExchange(code, redirectUri) : await exchangeCode(discord, code, redirectUri); } catch { return c.redirect('/?login=failed'); } const avatar = avatarUrl(du); const existing = d.users.findByOauth('discord', du.id); if (existing?.banned_at) return c.redirect('/?login=banned'); const current = requireUser(c) ? d.users.get(requireUser(c)!) : null; let userId: string; let merged = false; if (existing) { userId = existing.id; d.users.setAvatar(userId, avatar); if (current && current.kind === 'guest' && current.id !== existing.id) { d.users.mergeGuestInto(current.id, existing.id); merged = true; } } else if (current && current.kind === 'guest') { d.users.linkOauth(current.id, 'discord', du.id, du.username); d.users.promoteToMember(current.id, avatar); userId = current.id; merged = true; } else { const nick = normalizeNickname(du.global_name ?? du.username).slice(0, 12); const u = d.users.createMember(nicknameError(nick) ? '디스코드 사용자' : nick, avatar); d.users.linkOauth(u.id, 'discord', du.id, du.username); userId = u.id; } const old = getCookie(c, SESSION_COOKIE); if (old) d.sessions.revoke(old); startSession(c, userId, 'member'); const next = safeNext(parsed.next); return c.redirect(merged ? `${next}${next.includes('?') ? '&' : '?'}linked=1` : next); }); app.post('/api/auth/logout', (c) => { const token = getCookie(c, SESSION_COOKIE); if (token) d.sessions.revoke(token); deleteCookie(c, SESSION_COOKIE, { path: '/' }); return c.json({ ok: true }); }); app.post('/api/auth/logout-all', (c) => { const u = requireUser(c); if (!u) return c.json({ error: '로그인이 필요해요.' }, 401); d.sessions.revokeAll(u); deleteCookie(c, SESSION_COOKIE, { path: '/' }); return c.json({ ok: true }); }); app.get('/api/me', (c) => { const u = requireUser(c); const row = u ? d.users.get(u) : null; if (!row) return c.json({ me: null }); return c.json({ me: toPublicUser(row), settings: JSON.parse(row.settings_json), useAvatar: !!row.use_avatar, role: d.roles.roleOf(row.id), hasDiscordAvatar: !!row.avatar_url, activeRoom: d.rooms.activeRoomFor(row.id), }); }); app.patch('/api/me', async (c) => { const u = requireUser(c); if (!u) return c.json({ error: '로그인이 필요해요.' }, 401); const body = z .object({ nickname: z.string().max(100).optional(), useAvatar: z.boolean().optional(), settings: z .object({ fontScale: z.enum(['normal', 'large', 'xlarge']).optional(), theme: z.enum(['auto', 'light', 'dark']).optional(), sound: z.boolean().optional(), vibrate: z.boolean().optional(), colorBlind: z.boolean().optional(), tapConfirm: z.boolean().optional(), }) .optional(), }) .safeParse(await c.req.json().catch(() => null)); if (!body.success) return c.json({ error: '잘못된 요청이에요.' }, 400); if (body.data.nickname !== undefined) { const nick = normalizeNickname(body.data.nickname); const e = nicknameError(nick, d.settings.getSite().bannedWords) ?? d.users.changeNickname(u, nick); if (e) return c.json({ error: e }, 400); } d.users.updateSettings(u, { useAvatar: body.data.useAvatar, settings: body.data.settings }); return c.json({ me: toPublicUser(d.users.get(u)!) }); }); app.delete('/api/me', async (c) => { const u = requireUser(c); if (!u) return c.json({ error: '로그인이 필요해요.' }, 401); const body = z.object({ confirm: z.literal('탈퇴') }).safeParse(await c.req.json().catch(() => null)); if (!body.success) return c.json({ error: '확인 문구를 정확히 입력해 주세요.' }, 400); d.users.softDelete(u); deleteCookie(c, SESSION_COOKIE, { path: '/' }); return c.json({ ok: true }); }); app.get('/api/me/stats', (c) => { const u = requireUser(c); if (!u) return c.json({ error: '로그인이 필요해요.' }, 401); return c.json({ stats: d.users.stats(u) }); }); app.post('/api/rooms', async (c) => { const u = requireUser(c); if (!u) return c.json({ error: '로그인이 필요해요.' }, 401); if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429); const body = z .object({ gameId: z.string().max(40), visibility: z.enum(['private', 'public']).optional() }) .safeParse(await c.req.json().catch(() => null)); if (!body.success || !d.settings.game(body.data.gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400); const r = d.rooms.create(u, body.data.gameId, body.data.visibility); if (typeof r === 'string') return c.json({ error: r }, 429); return c.json({ code: r.code }); }); app.post('/api/quick/:gameId', (c) => { const u = requireUser(c); if (!u) return c.json({ error: '로그인이 필요해요.' }, 401); const gameId = c.req.param('gameId'); if (!d.settings.game(gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400); if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429); const r = d.rooms.quickPlay(u, gameId); if (typeof r === 'string') return c.json({ error: r }, 429); return c.json({ code: r.code }); }); /** Room preview for the join screen (no auth needed). */ app.get('/api/rooms/:code', (c) => { if (!lookupLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429); const code = normalizeRoomCode(c.req.param('code')); const r = code ? d.rooms.get(code) : undefined; if (!r || r.status === 'broken') return c.json({ error: '방을 찾을 수 없어요. 코드를 다시 확인해 주세요.' }, 404); const host = d.users.get(r.hostId); return c.json({ code: r.code, codeLabel: formatRoomCode(r.code), gameId: r.config.gameId, host: host?.nickname ?? '', seated: r.seatedIds().length, maxPlayers: r.config.maxPlayers, status: r.status, }); }); app.get('/api/games/:gameId/rooms', (c) => c.json({ rooms: d.rooms.publicRooms(c.req.param('gameId')) })); app.get('/api/public-rooms', (c) => c.json({ rooms: d.rooms.publicRooms(null).slice(0, 50) })); app.route( '/api/admin', createAdminApp({ ...d.admin, users: d.users, sessions: d.sessions, rooms: d.rooms, roles: d.roles, settings: d.settings }), ); return app; }