Files
joke-app/apps/server/src/http/app.ts
EJClaw f81f20a4bf 웹: PC 중심 화면으로 재구성
- 공통 상단 메뉴(사이트 이름·홈·게임 목록·관리자·어디서나 코드 입력·내 정보)
- 홈: 방 만들기 + 코드 입력 카드, 게임 격자, 지금 열린 공개 방 목록(/api/public-rooms)
- 대기실: 두 칸(초대·설정 바로 수정·규칙 요약 / 자리·시작 버튼·채팅 항상 보임)
- 게임 화면: 판(화면 높이에 맞춤, 좌표 표시) + 오른쪽 패널(상태·선수·시계·버튼·기보·채팅)
- 오목: 마우스 올리면 미리 보기 돌, PC는 한 번 클릭으로 착수(두 번 눌러 확정은 터치 기기 기본값,
  기기별 설정), GameUI에 Side 패널 추가
- 채팅: PC는 패널로 항상 표시(Enter로 바로 입력), 휴대폰은 시트
- 관리자: 왼쪽 사이드바, 사용자 표, 규칙 입력 두 칸 격자, 저장 바 고정
- 좁은 화면은 같은 화면을 한 칸으로 접어서 지원(방 상단 줄 줄바꿈 수정)
- E2E: PC 1440×900 기준(방장 PC, 친구 휴대폰), 관리자 E2E도 PC

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:04:00 +09:00

292 lines
12 KiB
TypeScript

/** 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<DiscordUser>;
ipOf: (req: Request) => string;
health: () => { ok: boolean; [k: string]: unknown };
roles: Roles;
settings: SettingsStore;
admin: Omit<AdminDeps, 'users' | 'sessions' | 'rooms' | 'roles' | 'settings'>;
}
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) => {
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;
}