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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user