- 권한: 디스코드 ID로 요청마다 계산. 슈퍼어드민 293719842274541579(SUPERADMIN_DISCORD_IDS), 어드민은 슈퍼어드민이 디스코드 ID로 추가(로그인 전 미리 등록 가능), 고정 슈퍼어드민은 삭제 불가 - 게임 설정: 사용 여부, 표시 이름·설명·안내문, 원작 범위 안 인원, 모든 규칙 옵션 기본값, 옵션별 방장 변경 금지(잠금), 초기화. 입력 화면은 옵션 스키마(zod→JSON Schema)로 자동 생성 - 오목 옵션 전체에 한국어 제목·선택지 메타, 엔진 고정값(자동 착수 허용 횟수, 무승부 재제안 간격, 한 수 제한 초)을 옵션으로 꺼냄 - 사이트 설정: 이름, 공지, 점검 모드, 금지어, 방 수 제한, 연결 유예, 채팅 기본값 - 방 관리(목록·닫기), 사용자 관리(검색·닉네임 변경·이용 제한·강제 로그아웃·삭제, 슈퍼어드민 전용), 관리자 작업 기록(전/후 값) - 이용 제한: 세션 삭제 + 접속 종료 + 디스코드 재로그인 거부 - 테스트: 관리자 API 7개(권한·설정 반영·잠금·점검·금지어·방 닫기·이용 제한·기록), 오목 옵션 2개, e2e/admin.e2e.ts(슈퍼어드민 로그인→규칙 변경·잠금→어드민 추가→게스트 새 방 반영) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
157 lines
5.9 KiB
TypeScript
157 lines
5.9 KiB
TypeScript
/** Wires DB, auth, rooms, HTTP and WebSocket into one Bun server (docs/02 §2). */
|
|
import { existsSync } from 'node:fs';
|
|
import { join, normalize } from 'node:path';
|
|
import type { Server } from 'bun';
|
|
import { GAMES } from '@bg/games';
|
|
import type { Config } from './config';
|
|
import { openDb } from './db';
|
|
import { SESSION_COOKIE, Sessions } from './auth/sessions';
|
|
import { Users, toPublicUser } from './auth/users';
|
|
import { createHttpApp, type HttpDeps } from './http/app';
|
|
import { RoomManager } from './rooms/manager';
|
|
import { RoomStore } from './rooms/store';
|
|
import { Gateway, type WsData } from './ws/gateway';
|
|
import { Audit, Roles } from './admin/roles';
|
|
import { SettingsStore } from './admin/settings';
|
|
|
|
export type LogFn = (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
|
|
|
export const jsonLog: LogFn = (level, msg, extra) => {
|
|
const line = JSON.stringify({ t: new Date().toISOString(), level, msg, ...extra });
|
|
if (level === 'error') console.error(line);
|
|
else console.log(line);
|
|
};
|
|
|
|
export interface StartOptions {
|
|
config: Config;
|
|
log?: LogFn;
|
|
staticDir?: string | null;
|
|
discordExchange?: HttpDeps['discordExchange'];
|
|
}
|
|
|
|
export function startServer(opts: StartOptions) {
|
|
const { config } = opts;
|
|
const log = opts.log ?? jsonLog;
|
|
const db = openDb(config.dbPath);
|
|
const users = new Users(db);
|
|
const sessions = new Sessions(db);
|
|
const store = new RoomStore(db);
|
|
const publicUser = (id: string) => {
|
|
const u = users.get(id);
|
|
return u ? toPublicUser(u) : null;
|
|
};
|
|
const settings = new SettingsStore(db, GAMES);
|
|
const roles = new Roles(db, config.superadminDiscordIds);
|
|
const audit = new Audit(db);
|
|
const startedAt = Date.now();
|
|
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings });
|
|
const restored = rooms.restoreAll();
|
|
log('info', 'rooms restored', restored);
|
|
const gateway = new Gateway({ rooms, me: publicUser, log });
|
|
let ready = true;
|
|
|
|
const ipOf = (req: Request) =>
|
|
(config.trustProxy ? req.headers.get('x-forwarded-for')?.split(',').pop()?.trim() : undefined) ||
|
|
server?.requestIP(req)?.address ||
|
|
'unknown';
|
|
|
|
const app = createHttpApp({
|
|
config,
|
|
users,
|
|
sessions,
|
|
rooms,
|
|
ipOf,
|
|
discordExchange: opts.discordExchange,
|
|
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),
|
|
roles,
|
|
settings,
|
|
admin: {
|
|
audit,
|
|
games: GAMES,
|
|
disconnectUser: (id) => gateway.disconnectUser(id),
|
|
stats: () => {
|
|
const all = rooms.all();
|
|
const day = Date.now() - 24 * 3600_000;
|
|
return {
|
|
connections: gateway.connectionCount,
|
|
rooms: { total: all.length, lobby: all.filter((r) => r.status === 'lobby').length, playing: all.filter((r) => r.status === 'playing').length },
|
|
games: db.query<{ total: number; today: number }, [number]>('SELECT COUNT(*) AS total, SUM(started_at > ?) AS today FROM games').get(day),
|
|
uptimeSec: Math.round((Date.now() - startedAt) / 1000),
|
|
memoryMb: Math.round(process.memoryUsage().rss / 1048576),
|
|
};
|
|
},
|
|
},
|
|
});
|
|
|
|
const staticDir = opts.staticDir === undefined ? join(import.meta.dir, '../../web/dist') : opts.staticDir;
|
|
const serveStatic = staticDir && existsSync(staticDir);
|
|
|
|
const server: Server<WsData> = Bun.serve<WsData>({
|
|
port: config.port,
|
|
async fetch(req, srv) {
|
|
const url = new URL(req.url);
|
|
if (url.pathname === '/ws') {
|
|
const origin = req.headers.get('origin');
|
|
if (!origin || !config.allowedOrigins.includes(origin)) return new Response('forbidden origin', { status: 403 });
|
|
const cookie = req.headers.get('cookie') ?? '';
|
|
const token = cookie
|
|
.split(';')
|
|
.map((s) => s.trim())
|
|
.find((s) => s.startsWith(`${SESSION_COOKIE}=`))
|
|
?.slice(SESSION_COOKIE.length + 1);
|
|
const userId = token ? sessions.resolve(decodeURIComponent(token)) : null;
|
|
if (!userId) return new Response('unauthorized', { status: 401 });
|
|
return gateway.upgrade(req, srv, userId);
|
|
}
|
|
if (url.pathname.startsWith('/api/') || url.pathname === '/healthz') return app.fetch(req);
|
|
if (url.pathname.startsWith('/internal/')) return new Response('not found', { status: 404 });
|
|
if (serveStatic) return serveFile(staticDir!, url.pathname);
|
|
return new Response('not found', { status: 404 });
|
|
},
|
|
websocket: {
|
|
...gateway.handlers,
|
|
idleTimeout: 40,
|
|
sendPings: true,
|
|
perMessageDeflate: true,
|
|
maxPayloadLength: 64 * 1024,
|
|
},
|
|
});
|
|
|
|
const sweeper = setInterval(() => {
|
|
try {
|
|
rooms.sweep();
|
|
sessions.purgeExpired();
|
|
store.purgeOldLogs();
|
|
} catch (err) {
|
|
log('error', 'sweep failed', { err: String(err) });
|
|
}
|
|
}, 60_000);
|
|
|
|
const stop = async () => {
|
|
ready = false;
|
|
clearInterval(sweeper);
|
|
gateway.shutdown();
|
|
rooms.shutdown();
|
|
// Bun's stop() promise can stay pending after server-initiated WS closes even though the
|
|
// listener is already closed, so bound the wait.
|
|
await Promise.race([server.stop(true), Bun.sleep(1000)]);
|
|
db.close();
|
|
};
|
|
|
|
return { server, stop, rooms, users, sessions, db, gateway, settings, roles };
|
|
}
|
|
|
|
async function serveFile(root: string, pathname: string): Promise<Response> {
|
|
const safe = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '');
|
|
const candidate = join(root, safe);
|
|
if (candidate.startsWith(root) && !candidate.endsWith('/')) {
|
|
const f = Bun.file(candidate);
|
|
if (await f.exists()) {
|
|
const immutable = safe.startsWith('/assets/');
|
|
return new Response(f, { headers: { 'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache' } });
|
|
}
|
|
}
|
|
// SPA fallback.
|
|
return new Response(Bun.file(join(root, 'index.html')), { headers: { 'Cache-Control': 'no-cache', 'Content-Type': 'text/html; charset=utf-8' } });
|
|
}
|