M1.5 관리자 사이트: 슈퍼어드민/어드민 권한, 게임·사이트 설정, 방·사용자 관리, 기록

- 권한: 디스코드 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>
This commit is contained in:
EJClaw
2026-10-04 04:52:38 +09:00
parent b2f998fa95
commit 242ab3ad71
31 changed files with 2009 additions and 68 deletions

View File

@@ -2,13 +2,16 @@
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 { 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;
@@ -19,6 +22,9 @@ export interface HttpDeps {
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 } };
@@ -68,14 +74,23 @@ export function createHttpApp(d: HttpDeps) {
return c.json(h, h.ok ? 200 : 503);
});
app.get('/api/config', (c) => c.json({ discord: d.config.discord !== null, catalog: CATALOG }));
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);
const e = nicknameError(nick, d.settings.getSite().bannedWords);
if (e) return c.json({ error: e }, 400);
const current = requireUser(c);
if (current) {
@@ -119,6 +134,7 @@ export function createHttpApp(d: HttpDeps) {
}
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;
@@ -170,6 +186,7 @@ export function createHttpApp(d: HttpDeps) {
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),
});
@@ -197,7 +214,7 @@ export function createHttpApp(d: HttpDeps) {
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);
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 });
@@ -227,7 +244,7 @@ export function createHttpApp(d: HttpDeps) {
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);
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 });
@@ -237,7 +254,7 @@ export function createHttpApp(d: HttpDeps) {
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 (!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);
@@ -264,5 +281,10 @@ export function createHttpApp(d: HttpDeps) {
app.get('/api/games/:gameId/rooms', (c) => c.json({ rooms: d.rooms.publicRooms(c.req.param('gameId')) }));
app.route(
'/api/admin',
createAdminApp({ ...d.admin, users: d.users, sessions: d.sessions, rooms: d.rooms, roles: d.roles, settings: d.settings }),
);
return app;
}