302 lines
24 KiB
JavaScript
302 lines
24 KiB
JavaScript
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { randomUUID } from 'node:crypto';
|
|
import Fastify from 'fastify';
|
|
import cookie from '@fastify/cookie';
|
|
import fastifyStatic from '@fastify/static';
|
|
import { isValidPlay } from '../src/playRepository.js';
|
|
import { createMongoStore, DEFAULT_MONGODB_DB, DEFAULT_MONGODB_URI } from './db.js';
|
|
import { isDevelopmentOrigin, loadConfig } from './config.js';
|
|
import { hashPassword, hashToken, newSessionToken, sessionExpiry, SESSION_COOKIE, SESSION_SECONDS, verifyPassword, validEmail } from './security.js';
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const projectRoot = path.resolve(here, '..');
|
|
const defaultStaticRoot = path.join(projectRoot, 'dist');
|
|
|
|
function nowDate() { return new Date(); }
|
|
function iso(value) { return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); }
|
|
function safeText(value, fallback = '') { return String(value ?? '').trim() || fallback; }
|
|
function publicUser(row) { return { id: row.id, email: row.email, displayName: row.displayName, status: row.status, isOperator: Boolean(row.isOperator) }; }
|
|
function publicTeam(row) { return { id: row.id, name: row.name, ownerUserId: row.ownerUserId, role: row.role, createdAt: iso(row.createdAt), updatedAt: iso(row.updatedAt) }; }
|
|
function publicJoinRequest(row) { return { id: row.id, teamId: row.teamId, userId: row.userId, status: row.status, role: row.role || null, createdAt: iso(row.createdAt), updatedAt: iso(row.updatedAt) }; }
|
|
function errorPayload(code, message) { return { error: { code, message } }; }
|
|
function fail(reply, status, code, message) { return reply.code(status).send(errorPayload(code, message)); }
|
|
function parsePositiveId(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value); }
|
|
function isProtectedConfigPath(url) { return /(?:^|\/)\.config\.json(?:\.[^/]*)?$/.test(String(url).split('?')[0]); }
|
|
function isRenewableApiRequest(request, allowedOrigins, mode) {
|
|
const url = String(request.url).split('?')[0];
|
|
if (!url.startsWith('/api/') || url === '/api/health') return false;
|
|
if (url === '/api/auth/login' || url === '/api/auth/register' || url === '/api/auth/logout' || url === '/api/auth/me') return false;
|
|
if (request.headers.origin && !requestOriginAllowed(request, allowedOrigins, mode)) return false;
|
|
return true;
|
|
}
|
|
function requestOriginAllowed(request, allowedOrigins, mode) {
|
|
const origin = request.headers.origin;
|
|
if (!origin) return true;
|
|
if (origin === 'null') return false;
|
|
if (mode === 'production' && isDevelopmentOrigin(origin)) return false;
|
|
if (allowedOrigins.includes(origin)) return true;
|
|
if (mode !== 'production' && isDevelopmentOrigin(origin)) return true;
|
|
return false;
|
|
}
|
|
function bodySchema(properties, required = []) { return { type: 'object', additionalProperties: false, required, properties }; }
|
|
const emailSchema = { type: 'string', minLength: 3, maxLength: 320 };
|
|
const passwordSchema = { type: 'string', minLength: 8, maxLength: 200 };
|
|
|
|
export async function buildServer(options = {}) {
|
|
const config = options.config || await loadConfig();
|
|
const mode = options.mode || config.server.mode;
|
|
const store = options.db || await createMongoStore({ config, uri: options.mongoUri || config.mongodb.uri, dbName: options.mongoDb || config.mongodb.db, client: options.mongoClient, serverSelectionTimeoutMS: options.serverSelectionTimeoutMs });
|
|
const staticRoot = options.staticRoot || defaultStaticRoot;
|
|
const allowedOrigins = options.allowedOrigins || (options.allowedOrigin ? String(options.allowedOrigin).split(',').map((origin) => origin.trim()).filter(Boolean) : config.server.allowedOrigins);
|
|
const secureCookie = mode === 'production' ? true : options.secureCookie ?? config.server.secureCookie;
|
|
const app = Fastify({ logger: options.logger ?? false, bodyLimit: 2_500_000 });
|
|
const loginAttempts = new Map(); const registerAttempts = new Map();
|
|
|
|
app.decorate('courtStore', store);
|
|
app.decorateRequest('user', null);
|
|
app.register(cookie);
|
|
|
|
app.addHook('onRequest', async (request, reply) => {
|
|
if (isProtectedConfigPath(request.url)) return fail(reply, 404, 'not_found', '요청한 경로를 찾을 수 없습니다');
|
|
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method) && !requestOriginAllowed(request, allowedOrigins, mode)) return fail(reply, 403, 'csrf_origin', '허용되지 않은 요청 출처입니다');
|
|
const token = request.cookies[SESSION_COOKIE];
|
|
if (!token) return;
|
|
const session = await store.sessions.findOne({ tokenHash: hashToken(token), expiresAt: { $gt: nowDate() } });
|
|
if (!session) return;
|
|
const now = nowDate();
|
|
const lastActivityAt = session.lastActivityAt || session.createdAt;
|
|
if (!lastActivityAt || now.getTime() - new Date(lastActivityAt).getTime() >= SESSION_SECONDS * 1000) return;
|
|
const row = await store.users.findOne({ id: session.userId });
|
|
if (!row || row.status === 'suspended') return;
|
|
request.user = row;
|
|
if (row.status === 'approved' && isRenewableApiRequest(request, allowedOrigins, mode)) {
|
|
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
|
|
await store.sessions.updateOne({ tokenHash: session.tokenHash, expiresAt: { $gt: now } }, { $set: { expiresAt, lastActivityAt: now } });
|
|
reply.setCookie(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: secureCookie, maxAge: SESSION_SECONDS });
|
|
}
|
|
});
|
|
|
|
app.addHook('onClose', async () => { await store.close(); });
|
|
|
|
const requireUser = async (request, reply) => {
|
|
if (!request.user) return fail(reply, 401, 'unauthenticated', '로그인이 필요합니다');
|
|
if (request.user.status !== 'approved') return fail(reply, 403, 'approval_required', '운영자 승인을 기다리는 계정입니다');
|
|
};
|
|
const requireOperator = async (request, reply) => {
|
|
const auth = await requireUser(request, reply); if (auth) return auth;
|
|
if (!request.user.isOperator) return fail(reply, 403, 'operator_required', '운영자 권한이 필요합니다');
|
|
};
|
|
async function teamMember(teamId, userId) {
|
|
const member = await store.teamMembers.findOne({ teamId, userId });
|
|
if (!member) return null;
|
|
const team = await store.teams.findOne({ id: teamId });
|
|
return team ? { ...team, role: member.role } : null;
|
|
}
|
|
const requireTeam = (minimum = 'viewer') => async (request, reply) => {
|
|
const auth = await requireUser(request, reply); if (auth) return auth;
|
|
const teamId = request.params.teamId;
|
|
if (!parsePositiveId(teamId)) return fail(reply, 400, 'invalid_team', '팀 식별자가 올바르지 않습니다');
|
|
const member = await teamMember(teamId, request.user.id);
|
|
if (!member) return fail(reply, 404, 'team_not_found', '팀을 찾을 수 없습니다');
|
|
const rank = { viewer: 1, editor: 2, owner: 3 };
|
|
if ((rank[member.role] || 0) < (rank[minimum] || 1)) return fail(reply, 403, 'team_forbidden', '팀 권한이 부족합니다');
|
|
request.team = member;
|
|
};
|
|
|
|
app.get('/api/health', async () => ({ ok: true }));
|
|
|
|
app.post('/api/auth/register', {
|
|
schema: { body: bodySchema({ email: emailSchema, password: passwordSchema, displayName: { type: 'string', minLength: 1, maxLength: 80 } }, ['email', 'password']) },
|
|
}, async (request, reply) => {
|
|
const email = safeText(request.body.email).toLowerCase(); const password = String(request.body.password || ''); const displayName = safeText(request.body.displayName, email.split('@')[0]).slice(0, 80);
|
|
const key = `${request.ip}:${email}`; const currentTime = Date.now(); const attempt = registerAttempts.get(key);
|
|
for (const [entry, value] of registerAttempts) if (value.resetAt <= currentTime) registerAttempts.delete(entry);
|
|
if (attempt && attempt.until > currentTime) return fail(reply, 429, 'register_throttled', '가입 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요');
|
|
const nextAttempt = attempt && attempt.resetAt > currentTime ? { count: attempt.count + 1, resetAt: attempt.resetAt } : { count: 1, resetAt: currentTime + 15 * 60 * 1000 };
|
|
if (nextAttempt.count >= 5) nextAttempt.until = currentTime + 60 * 1000;
|
|
if (registerAttempts.size >= 10_000) registerAttempts.delete(registerAttempts.keys().next().value); registerAttempts.set(key, nextAttempt);
|
|
if (!validEmail(email)) return fail(reply, 400, 'invalid_email', '이메일 형식이 올바르지 않습니다');
|
|
if (password.length < 8) return fail(reply, 400, 'invalid_password', '비밀번호는 8자 이상이어야 합니다');
|
|
if (await store.users.findOne({ email }, { projection: { id: 1 } })) return fail(reply, 409, 'email_exists', '이미 가입된 이메일입니다');
|
|
const createdAt = nowDate(); const id = randomUUID(); const passwordHash = await hashPassword(password);
|
|
try { await store.users.insertOne({ id, email, displayName, passwordHash, status: 'pending', isOperator: false, createdAt, updatedAt: createdAt }); }
|
|
catch (error) { if (error?.code === 11000) return fail(reply, 409, 'email_exists', '이미 가입된 이메일입니다'); throw error; }
|
|
return reply.code(201).send({ user: { id, email, displayName, status: 'pending', isOperator: false }, message: '가입 신청이 접수되었습니다. 운영자 승인을 기다려 주세요.' });
|
|
});
|
|
|
|
app.post('/api/auth/login', {
|
|
schema: { body: bodySchema({ email: emailSchema, password: { type: 'string', minLength: 1, maxLength: 200 } }, ['email', 'password']) },
|
|
}, async (request, reply) => {
|
|
const email = safeText(request.body.email).toLowerCase(); const password = String(request.body.password || ''); const key = `${request.ip}:${email}`; const currentTime = Date.now();
|
|
for (const [entry, value] of loginAttempts) if (value.resetAt <= currentTime) loginAttempts.delete(entry);
|
|
const attempt = loginAttempts.get(key);
|
|
if (attempt && attempt.until > currentTime) return fail(reply, 429, 'login_throttled', '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요');
|
|
const row = await store.users.findOne({ email }); const valid = row ? await verifyPassword(password, row.passwordHash) : false;
|
|
if (!valid) {
|
|
const next = attempt && attempt.resetAt > currentTime ? { count: attempt.count + 1, resetAt: attempt.resetAt } : { count: 1, resetAt: currentTime + 15 * 60 * 1000 };
|
|
if (next.count >= 10) next.until = currentTime + 30 * 1000;
|
|
if (loginAttempts.size >= 10_000) loginAttempts.delete(loginAttempts.keys().next().value);
|
|
loginAttempts.set(key, next); return fail(reply, 401, 'invalid_credentials', '이메일 또는 비밀번호가 올바르지 않습니다');
|
|
}
|
|
loginAttempts.delete(key);
|
|
if (row.status === 'pending') return fail(reply, 403, 'approval_required', '운영자 승인을 기다리는 계정입니다');
|
|
if (row.status === 'suspended') return fail(reply, 403, 'account_suspended', '사용이 중지된 계정입니다');
|
|
const token = newSessionToken(); const createdAt = nowDate();
|
|
await store.sessions.deleteMany({ expiresAt: { $lte: createdAt } });
|
|
await store.sessions.insertOne({ tokenHash: hashToken(token), userId: row.id, expiresAt: new Date(sessionExpiry(createdAt.getTime())), createdAt, lastActivityAt: createdAt });
|
|
reply.setCookie(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: secureCookie, maxAge: SESSION_SECONDS });
|
|
return { user: publicUser(row) };
|
|
});
|
|
|
|
app.post('/api/auth/logout', async (request, reply) => {
|
|
const token = request.cookies[SESSION_COOKIE]; if (token) await store.sessions.deleteOne({ tokenHash: hashToken(token) });
|
|
reply.clearCookie(SESSION_COOKIE, { path: '/' }); return { ok: true };
|
|
});
|
|
|
|
app.post('/api/auth/activity', { preHandler: requireUser, schema: { body: bodySchema({}, []) } }, async () => ({ ok: true }));
|
|
|
|
app.get('/api/auth/me', { preHandler: requireUser }, async (request) => ({ user: publicUser(request.user) }));
|
|
|
|
app.get('/api/teams', { preHandler: requireUser }, async (request) => {
|
|
const memberships = await store.teamMembers.find({ userId: request.user.id }).toArray();
|
|
const teams = await store.teams.find({ id: { $in: memberships.map((membership) => membership.teamId) } }).sort({ updatedAt: -1, name: 1 }).toArray();
|
|
const roles = new Map(memberships.map((membership) => [membership.teamId, membership.role]));
|
|
return { teams: teams.map((team) => publicTeam({ ...team, role: roles.get(team.id) })) };
|
|
});
|
|
|
|
app.post('/api/teams', {
|
|
preHandler: requireUser,
|
|
schema: { body: bodySchema({ name: { type: 'string', minLength: 1, maxLength: 100 } }, ['name']) },
|
|
}, async (request, reply) => {
|
|
const name = safeText(request.body.name); if (!name) return fail(reply, 400, 'invalid_team_name', '팀 이름을 입력해 주세요');
|
|
const id = randomUUID(); const createdAt = nowDate();
|
|
await store.teams.insertOne({ id, name, ownerUserId: request.user.id, createdAt, updatedAt: createdAt });
|
|
try { await store.teamMembers.insertOne({ teamId: id, userId: request.user.id, role: 'owner', createdAt }); }
|
|
catch (error) { await store.teams.deleteOne({ id, ownerUserId: request.user.id }); throw error; }
|
|
return reply.code(201).send({ team: publicTeam({ id, name, ownerUserId: request.user.id, role: 'owner', createdAt, updatedAt: createdAt }) });
|
|
});
|
|
|
|
app.get('/api/teams/search', { preHandler: requireUser }, async (request) => {
|
|
const query = safeText(request.query?.q).slice(0, 60);
|
|
if (query.length < 2) return { teams: [] };
|
|
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const rows = await store.teams.find({ name: { $regex: escaped, $options: 'i' } }, { projection: { _id: 0, id: 1, name: 1 } }).sort({ name: 1 }).limit(20).toArray();
|
|
return { teams: rows.slice(0, 20).map((team) => ({ id: team.id, name: team.name })) };
|
|
});
|
|
|
|
app.get('/api/team-requests', { preHandler: requireUser }, async (request) => {
|
|
const rows = await store.joinRequests.find({ userId: request.user.id }).sort({ updatedAt: -1 }).toArray();
|
|
const teams = await store.teams.find({ id: { $in: rows.map((row) => row.teamId) } }, { projection: { _id: 0, id: 1, name: 1 } }).toArray();
|
|
const names = new Map(teams.map((team) => [team.id, team.name]));
|
|
return { requests: rows.map((row) => ({ ...publicJoinRequest(row), teamName: names.get(row.teamId) || '삭제된 팀' })) };
|
|
});
|
|
|
|
app.post('/api/teams/:teamId/join-requests', { preHandler: requireUser, schema: { body: bodySchema({}, []) } }, async (request, reply) => {
|
|
if (!parsePositiveId(request.params.teamId)) return fail(reply, 400, 'invalid_team', '팀 식별자가 올바르지 않습니다');
|
|
const team = await store.teams.findOne({ id: request.params.teamId }); if (!team) return fail(reply, 404, 'team_not_found', '팀을 찾을 수 없습니다');
|
|
if (await store.teamMembers.findOne({ teamId: team.id, userId: request.user.id })) return fail(reply, 409, 'already_member', '이미 팀에 속해 있습니다');
|
|
const existing = await store.joinRequests.findOne({ teamId: team.id, userId: request.user.id });
|
|
if (existing) {
|
|
if (existing.status === 'pending' || existing.status === 'approved') return { request: publicJoinRequest(existing), existing: true };
|
|
const at = nowDate();
|
|
await store.joinRequests.updateOne({ id: existing.id, teamId: team.id, userId: request.user.id }, { $set: { status: 'pending', role: null, updatedAt: at } });
|
|
return { request: publicJoinRequest({ ...existing, status: 'pending', role: null, updatedAt: at }), existing: true };
|
|
}
|
|
const at = nowDate(); const row = { id: randomUUID(), teamId: team.id, userId: request.user.id, status: 'pending', role: null, createdAt: at, updatedAt: at };
|
|
try { await store.joinRequests.insertOne(row); } catch (error) { if (error?.code === 11000) { const current = await store.joinRequests.findOne({ teamId: team.id, userId: request.user.id }); return { request: publicJoinRequest(current), existing: true }; } throw error; }
|
|
return reply.code(201).send({ request: publicJoinRequest(row), existing: false });
|
|
});
|
|
|
|
app.get('/api/teams/:teamId/members', { preHandler: requireTeam('owner') }, async (request) => {
|
|
const members = await store.teamMembers.find({ teamId: request.params.teamId }).toArray();
|
|
const users = await store.users.find({ id: { $in: members.map((member) => member.userId) } }).toArray();
|
|
const byId = new Map(users.map((user) => [user.id, user]));
|
|
return { members: members.map((member) => ({ user: publicUser(byId.get(member.userId) || { id: member.userId, email: '', displayName: member.userId, status: 'unknown', isOperator: false }), role: member.role, createdAt: iso(member.createdAt) })) };
|
|
});
|
|
|
|
app.get('/api/teams/:teamId/join-requests', { preHandler: requireTeam('owner') }, async (request) => {
|
|
const rows = await store.joinRequests.find({ teamId: request.params.teamId, status: 'pending' }).sort({ createdAt: 1 }).toArray();
|
|
const users = await store.users.find({ id: { $in: rows.map((row) => row.userId) } }).toArray(); const byId = new Map(users.map((user) => [user.id, user]));
|
|
return { requests: rows.map((row) => ({ ...publicJoinRequest(row), user: publicUser(byId.get(row.userId) || { id: row.userId, email: '', displayName: row.userId, status: 'unknown', isOperator: false }) })) };
|
|
});
|
|
|
|
app.post('/api/teams/:teamId/join-requests/:requestId/approve', { preHandler: requireTeam('owner'), schema: { body: bodySchema({ role: { type: 'string', enum: ['viewer', 'editor'] } }, []) } }, async (request, reply) => {
|
|
if (request.team.ownerUserId !== request.user.id) return fail(reply, 403, 'team_forbidden', '팀 소유자만 가입 요청을 승인할 수 있습니다');
|
|
const requestedRole = request.body?.role || 'viewer'; const requestRow = await store.joinRequests.findOne({ id: request.params.requestId, teamId: request.params.teamId });
|
|
if (requestRow?.status === 'approved') {
|
|
const existingMember = await store.teamMembers.findOne({ teamId: request.params.teamId, userId: requestRow.userId });
|
|
if (existingMember) return { ok: true, existing: true, member: { user: publicUser(await store.users.findOne({ id: requestRow.userId }) || { id: requestRow.userId, email: '', displayName: requestRow.userId, status: 'unknown', isOperator: false }), role: existingMember.role } };
|
|
}
|
|
if (requestRow?.status !== 'pending') return fail(reply, 404, 'request_not_found', '가입 요청을 찾을 수 없습니다');
|
|
const applicant = await store.users.findOne({ id: requestRow.userId, status: 'approved' });
|
|
if (!applicant) return fail(reply, 409, 'applicant_not_approved', '승인된 계정만 팀에 추가할 수 있습니다');
|
|
const at = nowDate();
|
|
let memberRole = requestedRole;
|
|
try { await store.teamMembers.insertOne({ teamId: request.params.teamId, userId: requestRow.userId, role: requestedRole, createdAt: at }); }
|
|
catch (error) { if (error?.code !== 11000) throw error; const existing = await store.teamMembers.findOne({ teamId: request.params.teamId, userId: requestRow.userId }); memberRole = existing?.role || requestedRole; }
|
|
await store.joinRequests.updateOne({ id: requestRow.id, status: 'pending' }, { $set: { status: 'approved', role: memberRole, updatedAt: at } });
|
|
return { ok: true, member: { user: publicUser(applicant), role: memberRole } };
|
|
});
|
|
|
|
app.patch('/api/teams/:teamId/members/:userId', { preHandler: requireTeam('owner'), schema: { body: bodySchema({ role: { type: 'string', enum: ['viewer', 'editor'] } }, ['role']) } }, async (request, reply) => {
|
|
if (request.team.ownerUserId !== request.user.id) return fail(reply, 403, 'team_forbidden', '팀 소유자만 멤버 권한을 변경할 수 있습니다');
|
|
const userId = request.params.userId; if (userId === request.user.id) return fail(reply, 400, 'owner_immutable', '소유자 권한은 변경할 수 없습니다');
|
|
const member = await store.teamMembers.findOne({ teamId: request.params.teamId, userId }); if (!member) return fail(reply, 404, 'member_not_found', '팀 멤버를 찾을 수 없습니다');
|
|
if (member.role === 'owner') return fail(reply, 400, 'owner_immutable', '소유자 권한은 변경할 수 없습니다');
|
|
await store.teamMembers.updateOne({ teamId: request.params.teamId, userId }, { $set: { role: request.body.role, updatedAt: nowDate() } }); return { ok: true, userId, role: request.body.role };
|
|
});
|
|
|
|
app.get('/api/teams/:teamId', { preHandler: requireTeam('viewer') }, async (request) => ({ team: publicTeam(request.team) }));
|
|
|
|
app.get('/api/teams/:teamId/plays', { preHandler: requireTeam('viewer') }, async (request) => {
|
|
const rows = await store.plays.find({ teamId: request.params.teamId }, { projection: { _id: 0, id: 1, name: 1, updatedAt: 1 } }).sort({ updatedAt: -1, name: 1 }).toArray();
|
|
return { plays: rows.map((row) => ({ id: row.id, name: row.name, updatedAt: iso(row.updatedAt) })) };
|
|
});
|
|
|
|
app.get('/api/teams/:teamId/plays/:playId', { preHandler: requireTeam('viewer') }, async (request, reply) => {
|
|
if (!parsePositiveId(request.params.playId)) return fail(reply, 400, 'invalid_play', '전술 식별자가 올바르지 않습니다');
|
|
const row = await store.plays.findOne({ teamId: request.params.teamId, id: request.params.playId }, { projection: { _id: 0, data: 1 } });
|
|
if (!row) return fail(reply, 404, 'play_not_found', '전술을 찾을 수 없습니다');
|
|
return { play: row.data };
|
|
});
|
|
|
|
app.put('/api/teams/:teamId/plays/:playId', {
|
|
preHandler: requireTeam('editor'),
|
|
schema: { body: bodySchema({ play: { type: 'object' } }, ['play']) },
|
|
}, async (request, reply) => {
|
|
const { play } = request.body; let valid = false; try { valid = Boolean(play && isValidPlay(play)); } catch { valid = false; }
|
|
if (!parsePositiveId(request.params.playId) || !play || play.id !== request.params.playId || !valid) return fail(reply, 400, 'invalid_play', '저장할 전술 데이터가 올바르지 않습니다');
|
|
const name = safeText(play.name, '새 전술').slice(0, 160); const copy = structuredClone(play); copy.name = name; const updatedAt = nowDate();
|
|
const result = await store.plays.findOneAndUpdate({ teamId: request.params.teamId, id: request.params.playId }, { $set: { name, data: copy, updatedAt, updatedBy: request.user.id }, $setOnInsert: { createdAt: updatedAt } }, { upsert: true, returnDocument: 'after', includeResultMetadata: true });
|
|
return reply.code(result?.lastErrorObject?.upserted ? 201 : 200).send({ id: copy.id, name, updatedAt: updatedAt.toISOString(), play: copy });
|
|
});
|
|
|
|
app.get('/api/admin/users', { preHandler: requireOperator }, async (request) => {
|
|
const status = ['pending', 'approved', 'suspended'].includes(request.query?.status) ? request.query.status : 'pending';
|
|
const rows = await store.users.find({ status }).sort({ createdAt: 1 }).toArray(); return { users: rows.map(publicUser) };
|
|
});
|
|
|
|
app.post('/api/admin/users/:userId/approve', { preHandler: requireOperator }, async (request, reply) => {
|
|
const result = await store.users.findOneAndUpdate({ id: request.params.userId, status: 'pending' }, { $set: { status: 'approved', updatedAt: nowDate() } }, { returnDocument: 'after' });
|
|
const user = result?.value || result; if (!user) return fail(reply, 404, 'user_not_found', '승인 대기 사용자를 찾을 수 없습니다'); return { user: publicUser(user) };
|
|
});
|
|
|
|
app.post('/api/admin/users/:userId/suspend', { preHandler: requireOperator, schema: { body: bodySchema({}, []) } }, async (request, reply) => {
|
|
const result = await store.users.updateOne({ id: request.params.userId }, { $set: { status: 'suspended', updatedAt: nowDate() } });
|
|
if (!result.modifiedCount) return fail(reply, 404, 'user_not_found', '사용자를 찾을 수 없습니다');
|
|
await store.sessions.deleteMany({ userId: request.params.userId }); return { ok: true };
|
|
});
|
|
|
|
if (options.serveStatic !== false) {
|
|
app.register(fastifyStatic, { root: staticRoot, prefix: '/', decorateReply: true });
|
|
app.setNotFoundHandler((request, reply) => {
|
|
if (request.method === 'GET' && !request.url.startsWith('/api/')) return reply.sendFile('index.html');
|
|
return fail(reply, 404, 'not_found', '요청한 경로를 찾을 수 없습니다');
|
|
});
|
|
}
|
|
return app;
|
|
}
|
|
|
|
export { DEFAULT_MONGODB_DB, DEFAULT_MONGODB_URI };
|