Initial commit: courtlab tactical board with team management and MP4 export
This commit is contained in:
+213
@@ -0,0 +1,213 @@
|
||||
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 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/: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 };
|
||||
Reference in New Issue
Block a user