Files
basket_utils/server/app.js
T

285 lines
23 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, 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 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 row = await store.users.findOne({ id: session.userId });
if (!row || row.status === 'suspended') return;
request.user = row;
});
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 });
reply.setCookie(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: secureCookie, maxAge: 7 * 24 * 60 * 60 });
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.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 };