M1 기반: 모노레포, 공통 엔진, 공용 프로토콜, 오목 엔진, 서버 골격
- packages/engine: 시드 RNG(sfc32), GameDefinition 타입, 무작위 대국·정보 유출 테스트 도구 - packages/shared: WS 프로토콜 스키마, 닉네임·방 코드 규칙, 게임 카탈로그 - packages/games/omok: 한국식·렌주·자유룰, 재귀 금수 판정, 무르기·무승부·시계 (테스트 27개) - apps/server: SQLite 스키마, 게스트·디스코드 로그인, 세션, 방 관리(저장·복구·타이머·재접속), WS 게이트웨이, HTTP API Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
268
apps/server/src/http/app.ts
Normal file
268
apps/server/src/http/app.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/** 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 { CATALOG, catalogById, 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';
|
||||
|
||||
export interface HttpDeps {
|
||||
config: Config;
|
||||
users: Users;
|
||||
sessions: Sessions;
|
||||
rooms: RoomManager;
|
||||
/** Injected for tests. */
|
||||
discordExchange?: (code: string, redirectUri: string) => Promise<DiscordUser>;
|
||||
ipOf: (req: Request) => string;
|
||||
health: () => { ok: boolean; [k: string]: unknown };
|
||||
}
|
||||
|
||||
type Env = { Variables: { userId: string | null } };
|
||||
|
||||
const OAUTH_COOKIE = 'oauth';
|
||||
|
||||
export function createHttpApp(d: HttpDeps) {
|
||||
const app = new Hono<Env>();
|
||||
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<Env>) => c.get('userId');
|
||||
|
||||
const startSession = (c: Context<Env>, 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) => c.json({ discord: d.config.discord !== null, catalog: CATALOG }));
|
||||
|
||||
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);
|
||||
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);
|
||||
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,
|
||||
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.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 || !catalogById(body.data.gameId)?.available) 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 (!catalogById(gameId)?.available) 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')) }));
|
||||
|
||||
return app;
|
||||
}
|
||||
Reference in New Issue
Block a user