|
|
|
|
|
import { createServer } from 'node:http';
|
|
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
|
|
import { Server } from 'socket.io';
|
|
|
|
|
|
import { z } from 'zod';
|
|
|
|
|
|
import {
|
|
|
|
|
|
authenticate,
|
|
|
|
|
|
cookieToken,
|
|
|
|
|
|
hashPin,
|
|
|
|
|
|
hashToken,
|
|
|
|
|
|
newSession,
|
|
|
|
|
|
publicUser,
|
|
|
|
|
|
sessionCookie,
|
|
|
|
|
|
verifyPassword,
|
|
|
|
|
|
verifyPin,
|
|
|
|
|
|
} from './auth.mjs';
|
|
|
|
|
|
import { Platform } from './platform.mjs';
|
|
|
|
|
|
import { catalog } from './games/registry.mjs';
|
|
|
|
|
|
import { validateAvatar } from './avatar.mjs';
|
|
|
|
|
|
import { terms } from '../shared/terms.mjs';
|
|
|
|
|
|
import { termsAcceptance, termsCookie, clearTermsCookie } from './terms.mjs';
|
|
|
|
|
|
|
|
|
|
|
|
const registerSchema = z.object({
|
|
|
|
|
|
email: z
|
|
|
|
|
|
.email()
|
|
|
|
|
|
.max(254)
|
|
|
|
|
|
.transform((v) => v.toLowerCase().trim()),
|
|
|
|
|
|
name: z.string().trim().min(2).max(60),
|
|
|
|
|
|
nick: z
|
|
|
|
|
|
.string()
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.regex(/^[a-zA-Z0-9_]{3,20}$/),
|
|
|
|
|
|
pin: z.string().regex(/^\d{4}$/),
|
|
|
|
|
|
});
|
|
|
|
|
|
const loginSchema = z.object({
|
|
|
|
|
|
email: z
|
|
|
|
|
|
.email()
|
|
|
|
|
|
.max(254)
|
|
|
|
|
|
.transform((v) => v.toLowerCase().trim()),
|
|
|
|
|
|
pin: z.string().regex(/^\d{4}$/),
|
|
|
|
|
|
});
|
|
|
|
|
|
const createSchema = z.object({
|
|
|
|
|
|
gameId: z.string().regex(/^[a-z][a-z0-9-]{1,30}$/),
|
|
|
|
|
|
name: z.string().trim().min(3).max(60),
|
|
|
|
|
|
visibility: z.enum(['public', 'private']),
|
|
|
|
|
|
maxPlayers: z.number().int().min(2).max(4),
|
|
|
|
|
|
invitationMessage: z.string().trim().max(300).default(''),
|
|
|
|
|
|
});
|
|
|
|
|
|
const idSchema = z.string().uuid();
|
|
|
|
|
|
// The transport validates the envelope; each trusted game validates its actions.
|
|
|
|
|
|
const actionSchema = z.object({
|
|
|
|
|
|
roomId: idSchema,
|
|
|
|
|
|
actionId: z.string().uuid(),
|
|
|
|
|
|
expectedVersion: z.number().int().nonnegative(),
|
|
|
|
|
|
action: z
|
|
|
|
|
|
.object({ type: z.string().regex(/^[a-z][a-z0-9-]{0,31}$/) })
|
|
|
|
|
|
.catchall(z.unknown()),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
class RateLimit {
|
|
|
|
|
|
constructor() {
|
|
|
|
|
|
this.buckets = new Map();
|
|
|
|
|
|
}
|
|
|
|
|
|
check(key, limit, duration) {
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
if (this.buckets.size > 10000)
|
|
|
|
|
|
for (const [id, b] of this.buckets)
|
|
|
|
|
|
if (b.until < now) this.buckets.delete(id);
|
|
|
|
|
|
const bucket = this.buckets.get(key);
|
|
|
|
|
|
if (!bucket || bucket.until < now) {
|
|
|
|
|
|
this.buckets.set(key, { count: 1, until: now + duration });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (++bucket.count > limit)
|
|
|
|
|
|
throw new Error('Demasiadas solicitudes. Espera un momento.');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function body(req, maxSize = 16384) {
|
|
|
|
|
|
if (!req.headers['content-type']?.startsWith('application/json'))
|
|
|
|
|
|
throw new Error('Se necesita contenido JSON.');
|
|
|
|
|
|
let size = 0;
|
|
|
|
|
|
const chunks = [];
|
|
|
|
|
|
for await (const chunk of req) {
|
|
|
|
|
|
size += chunk.length;
|
|
|
|
|
|
if (size > maxSize) throw new Error('Solicitud demasiado grande.');
|
|
|
|
|
|
chunks.push(chunk);
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
throw new Error('Solicitud JSON no válida.');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @param {import('node:http').RequestListener | null} [webHandler] */
|
|
|
|
|
|
export function createService(store, env = process.env, webHandler = null) {
|
|
|
|
|
|
const platform = new Platform(store);
|
|
|
|
|
|
const limits = new RateLimit();
|
|
|
|
|
|
const production = env.NODE_ENV === 'production';
|
|
|
|
|
|
if (production && (!env.PIN_PEPPER || env.PIN_PEPPER.length < 32))
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
'Producción requiere PIN_PEPPER de al menos 32 caracteres.',
|
|
|
|
|
|
);
|
|
|
|
|
|
const pepper = env.PIN_PEPPER || 'enronda-development-only-pin-pepper';
|
|
|
|
|
|
if (production && (!env.APP_ORIGIN || !env.APP_ORIGIN.startsWith('https://')))
|
|
|
|
|
|
throw new Error('Producción requiere APP_ORIGIN HTTPS.');
|
|
|
|
|
|
const origins = new Set(
|
|
|
|
|
|
(env.APP_ORIGIN || 'http://localhost:4173')
|
|
|
|
|
|
.split(',')
|
|
|
|
|
|
.map((s) => new URL(s.trim()).origin),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!production) {
|
|
|
|
|
|
origins.add('http://127.0.0.1:4173');
|
|
|
|
|
|
origins.add('http://localhost:4173');
|
|
|
|
|
|
}
|
|
|
|
|
|
const server = createServer();
|
|
|
|
|
|
const io = new Server(server, {
|
|
|
|
|
|
maxHttpBufferSize: 16384,
|
|
|
|
|
|
cors: { origin: [...origins], credentials: true },
|
|
|
|
|
|
allowRequest: (req, cb) => cb(null, origins.has(req.headers.origin ?? '')),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const withPresence = (room) => ({
|
|
|
|
|
|
...room,
|
|
|
|
|
|
players: room.players.map((p) => ({
|
|
|
|
|
|
...p,
|
|
|
|
|
|
connected: [...io.sockets.sockets.values()].some(
|
|
|
|
|
|
(s) => s.data.user.id === p.userId && s.rooms.has(room.id),
|
|
|
|
|
|
),
|
|
|
|
|
|
})),
|
|
|
|
|
|
});
|
|
|
|
|
|
async function broadcast(roomId) {
|
|
|
|
|
|
const storedRoom = await store.room(roomId);
|
|
|
|
|
|
const sockets = [...io.sockets.sockets.values()].filter((s) =>
|
|
|
|
|
|
s.rooms.has(roomId),
|
|
|
|
|
|
);
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
|
sockets.map(async (socket) => {
|
|
|
|
|
|
const user = await authenticate(store, socket.data.token);
|
|
|
|
|
|
if (
|
|
|
|
|
|
!user ||
|
|
|
|
|
|
!termsAcceptance(socket.request.headers.cookie, pepper).accepted
|
|
|
|
|
|
) {
|
|
|
|
|
|
socket.disconnect(true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!storedRoom?.players.some((p) => p.userId === user.id)) {
|
|
|
|
|
|
socket.emit('room:removed', { roomId });
|
|
|
|
|
|
await socket.leave(roomId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
socket.emit(
|
|
|
|
|
|
'room:state',
|
|
|
|
|
|
withPresence(await platform.view(roomId, user.id)),
|
|
|
|
|
|
);
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
const changed = () => io.emit('lobby:changed');
|
|
|
|
|
|
let sweeping = false;
|
|
|
|
|
|
async function sweep() {
|
|
|
|
|
|
if (sweeping) return;
|
|
|
|
|
|
sweeping = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const closed = await platform.expireInactiveRooms();
|
|
|
|
|
|
for (const roomId of closed) await broadcast(roomId);
|
|
|
|
|
|
if (closed.length) changed();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(
|
|
|
|
|
|
'No se pudo revisar la inactividad de las partidas:',
|
|
|
|
|
|
error.message,
|
|
|
|
|
|
);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
sweeping = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let sweepPromise = Promise.resolve();
|
|
|
|
|
|
const scheduleSweep = () => {
|
|
|
|
|
|
sweepPromise = sweep();
|
|
|
|
|
|
};
|
|
|
|
|
|
const expiryTimer = setInterval(scheduleSweep, 60_000);
|
|
|
|
|
|
expiryTimer.unref();
|
|
|
|
|
|
scheduleSweep();
|
|
|
|
|
|
io.use(async (socket, next) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!termsAcceptance(socket.request.headers.cookie, pepper).accepted)
|
|
|
|
|
|
return next(new Error('Acepta las condiciones de uso para conectar.'));
|
|
|
|
|
|
const token = cookieToken(socket.request.headers.cookie);
|
|
|
|
|
|
const user = await authenticate(store, token);
|
|
|
|
|
|
if (!user) return next(new Error('Inicia sesión para conectar.'));
|
|
|
|
|
|
socket.data = { user, token };
|
|
|
|
|
|
next();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
next(new Error('No se pudo validar la sesión.'));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
io.on('connection', (socket) => {
|
|
|
|
|
|
socket.on('room:unsubscribe', (input) => {
|
|
|
|
|
|
if (input && typeof input.roomId === 'string') {
|
|
|
|
|
|
socket.leave(input.roomId);
|
|
|
|
|
|
broadcast(input.roomId).catch(console.error);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
function event(name, schema, handler) {
|
|
|
|
|
|
socket.on(name, async (raw, ack) => {
|
|
|
|
|
|
if (typeof ack !== 'function') return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (
|
|
|
|
|
|
!termsAcceptance(socket.request.headers.cookie, pepper).accepted
|
|
|
|
|
|
) {
|
|
|
|
|
|
socket.disconnect(true);
|
|
|
|
|
|
throw new Error('Acepta las condiciones de uso para continuar.');
|
|
|
|
|
|
}
|
|
|
|
|
|
limits.check(`ws:${socket.data.user.id}`, 120, 60000);
|
|
|
|
|
|
const user = await authenticate(store, socket.data.token);
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
|
socket.disconnect(true);
|
|
|
|
|
|
throw new Error('La sesión ha caducado.');
|
|
|
|
|
|
}
|
|
|
|
|
|
const input = schema.parse(raw);
|
|
|
|
|
|
const roomId = await handler(user, input);
|
|
|
|
|
|
const room = withPresence(await platform.view(roomId, user.id));
|
|
|
|
|
|
ack({ ok: true, room });
|
|
|
|
|
|
await broadcast(roomId);
|
|
|
|
|
|
changed();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
const internal = e.code || /database|connect|query/i.test(e.message);
|
|
|
|
|
|
if (internal) console.error('Error del servicio:', e.message);
|
|
|
|
|
|
ack({
|
|
|
|
|
|
ok: false,
|
|
|
|
|
|
error:
|
|
|
|
|
|
e instanceof z.ZodError
|
|
|
|
|
|
? 'Solicitud no válida.'
|
|
|
|
|
|
: internal
|
|
|
|
|
|
? 'No se pudo completar la operación.'
|
|
|
|
|
|
: e.message,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
event(
|
|
|
|
|
|
'room:subscribe',
|
|
|
|
|
|
z.object({ roomId: idSchema }),
|
|
|
|
|
|
async (user, input) => {
|
|
|
|
|
|
await platform.view(input.roomId, user.id);
|
|
|
|
|
|
await socket.join(input.roomId);
|
|
|
|
|
|
return input.roomId;
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
event(
|
|
|
|
|
|
'room:ready',
|
|
|
|
|
|
z.object({ roomId: idSchema, ready: z.boolean() }),
|
|
|
|
|
|
async (user, input) => {
|
|
|
|
|
|
await platform.ready(user, input.roomId, input.ready);
|
|
|
|
|
|
return input.roomId;
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
event(
|
|
|
|
|
|
'room:color',
|
|
|
|
|
|
z.object({
|
|
|
|
|
|
roomId: idSchema,
|
|
|
|
|
|
colorId: z.enum([
|
|
|
|
|
|
'amarillo',
|
|
|
|
|
|
'azul',
|
|
|
|
|
|
'rojo',
|
|
|
|
|
|
'verde',
|
|
|
|
|
|
'negro',
|
|
|
|
|
|
'naranja',
|
|
|
|
|
|
'violeta',
|
|
|
|
|
|
'gris',
|
|
|
|
|
|
]),
|
|
|
|
|
|
}),
|
|
|
|
|
|
async (user, input) => {
|
|
|
|
|
|
await platform.chooseColor(user, input.roomId, input.colorId);
|
|
|
|
|
|
return input.roomId;
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
event('game:action', actionSchema, async (user, input) => {
|
|
|
|
|
|
await platform.action(user, input);
|
|
|
|
|
|
return input.roomId;
|
|
|
|
|
|
});
|
|
|
|
|
|
event(
|
|
|
|
|
|
'chat:send',
|
|
|
|
|
|
z.object({ roomId: idSchema, text: z.string().trim().min(1).max(500) }),
|
|
|
|
|
|
async (user, input) => {
|
|
|
|
|
|
limits.check(`chat:${user.id}`, 15, 10000);
|
|
|
|
|
|
await platform.message(user, input.roomId, input.text);
|
|
|
|
|
|
return input.roomId;
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
socket.on('disconnecting', () => {
|
|
|
|
|
|
const rooms = [...socket.rooms].filter((r) => r !== socket.id);
|
|
|
|
|
|
setTimeout(
|
|
|
|
|
|
() => rooms.forEach((id) => broadcast(id).catch(console.error)),
|
|
|
|
|
|
0,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
server.on('request', async (req, res) => {
|
|
|
|
|
|
if (req.url?.startsWith('/socket.io')) return;
|
|
|
|
|
|
const send = (status, value, cookie) => {
|
|
|
|
|
|
const headers = {
|
|
|
|
|
|
'content-type': 'application/json; charset=utf-8',
|
|
|
|
|
|
'cache-control': 'no-store',
|
|
|
|
|
|
'x-content-type-options': 'nosniff',
|
|
|
|
|
|
};
|
|
|
|
|
|
if (cookie) headers['set-cookie'] = cookie;
|
|
|
|
|
|
res.writeHead(status, headers);
|
|
|
|
|
|
res.end(JSON.stringify(value));
|
|
|
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
|
|
|
|
const path = url.pathname;
|
|
|
|
|
|
if (!path.startsWith('/api/')) {
|
|
|
|
|
|
if (webHandler) {
|
|
|
|
|
|
await webHandler(req, res);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
send(404, { error: 'Ruta no encontrada.' });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const ip =
|
|
|
|
|
|
env.TRUST_PROXY === '1'
|
|
|
|
|
|
? String(req.headers['x-forwarded-for'] || req.socket.remoteAddress)
|
|
|
|
|
|
.split(',')[0]
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
: req.socket.remoteAddress;
|
|
|
|
|
|
limits.check(`http:${ip}`, 240, 60000);
|
|
|
|
|
|
if (req.method !== 'GET' && !origins.has(req.headers.origin ?? '')) {
|
|
|
|
|
|
send(403, { error: 'Origen de la solicitud no permitido.' });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Only explicitly allowed LAN HTTP origins use non-Secure cookies.
|
|
|
|
|
|
// Public HTTPS origins keep Secure even behind an HTTP reverse proxy.
|
|
|
|
|
|
const secureCookies =
|
|
|
|
|
|
production && !String(req.headers.origin ?? '').startsWith('http://');
|
|
|
|
|
|
const token = cookieToken(req.headers.cookie);
|
|
|
|
|
|
const user = await authenticate(store, token);
|
|
|
|
|
|
const acceptance = termsAcceptance(req.headers.cookie, pepper);
|
|
|
|
|
|
const acceptanceRecord = acceptance.accepted
|
|
|
|
|
|
? { version: acceptance.version, acceptedAt: acceptance.acceptedAt }
|
|
|
|
|
|
: null;
|
|
|
|
|
|
if (path === '/api/health' && req.method === 'GET') {
|
|
|
|
|
|
await store.listRooms(null);
|
|
|
|
|
|
send(200, {
|
|
|
|
|
|
status: 'ok',
|
|
|
|
|
|
storage: env.DATABASE_URL ? 'postgresql' : 'local',
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path === '/api/catalog' && req.method === 'GET') {
|
|
|
|
|
|
send(200, { games: catalog });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path === '/api/session' && req.method === 'GET') {
|
|
|
|
|
|
send(200, { user, terms: acceptance });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
path === '/api/rooms' &&
|
|
|
|
|
|
req.method === 'GET' &&
|
|
|
|
|
|
!acceptance.accepted
|
|
|
|
|
|
) {
|
|
|
|
|
|
send(200, { rooms: await platform.list(null) });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path === '/api/terms/accept' && req.method === 'POST') {
|
|
|
|
|
|
limits.check(`terms:${ip}`, 20, 60000);
|
|
|
|
|
|
z.object({
|
|
|
|
|
|
accepted: z.literal(true),
|
|
|
|
|
|
version: z.literal(terms.version),
|
|
|
|
|
|
}).parse(await body(req));
|
|
|
|
|
|
const cookie = termsCookie(pepper, secureCookies);
|
|
|
|
|
|
const accepted = termsAcceptance(cookie, pepper);
|
|
|
|
|
|
if (user)
|
|
|
|
|
|
await store.updateUser(user.id, (stored) => {
|
|
|
|
|
|
stored.termsAcceptance = {
|
|
|
|
|
|
version: accepted.version,
|
|
|
|
|
|
acceptedAt: accepted.acceptedAt,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
send(200, { terms: accepted }, cookie);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path === '/api/terms/revoke' && req.method === 'POST') {
|
|
|
|
|
|
if (user)
|
|
|
|
|
|
await store.updateUser(user.id, (stored) => {
|
|
|
|
|
|
delete stored.termsAcceptance;
|
|
|
|
|
|
});
|
|
|
|
|
|
for (const socket of io.sockets.sockets.values())
|
|
|
|
|
|
if (token && socket.data.token === token) socket.disconnect(true);
|
|
|
|
|
|
send(
|
|
|
|
|
|
200,
|
|
|
|
|
|
{
|
|
|
|
|
|
terms: {
|
|
|
|
|
|
accepted: false,
|
|
|
|
|
|
version: terms.version,
|
|
|
|
|
|
acceptedAt: null,
|
|
|
|
|
|
expiresAt: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
clearTermsCookie(secureCookies),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const publicRequest =
|
|
|
|
|
|
(path.startsWith('/api/invites/') && req.method === 'GET') ||
|
|
|
|
|
|
(path === '/api/auth/logout' && req.method === 'POST');
|
|
|
|
|
|
if (!acceptance.accepted && !publicRequest) {
|
|
|
|
|
|
send(428, {
|
|
|
|
|
|
code: 'TERMS_REQUIRED',
|
|
|
|
|
|
error: 'Acepta las condiciones de uso para continuar.',
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path === '/api/auth/register' && req.method === 'POST') {
|
|
|
|
|
|
limits.check(`auth:${ip}`, 10, 15 * 60000);
|
|
|
|
|
|
const input = registerSchema.parse(await body(req));
|
|
|
|
|
|
const { pin, ...identity } = input;
|
|
|
|
|
|
const account = {
|
|
|
|
|
|
...identity,
|
|
|
|
|
|
pinHash: await hashPin(pin, pepper),
|
|
|
|
|
|
termsAcceptance: acceptanceRecord,
|
|
|
|
|
|
guest: false,
|
|
|
|
|
|
id: randomUUID(),
|
|
|
|
|
|
createdAt: new Date().toISOString(),
|
|
|
|
|
|
};
|
|
|
|
|
|
if (user?.guest) {
|
|
|
|
|
|
account.id = user.id;
|
|
|
|
|
|
await store.updateUser(user.id, (stored) => {
|
|
|
|
|
|
account.createdAt = stored.createdAt;
|
|
|
|
|
|
account.avatar = stored.avatar;
|
|
|
|
|
|
Object.assign(stored, account);
|
|
|
|
|
|
delete stored.password;
|
|
|
|
|
|
});
|
|
|
|
|
|
} else await store.createUser(account);
|
|
|
|
|
|
const token = await newSession(store, account.id);
|
|
|
|
|
|
send(
|
|
|
|
|
|
201,
|
|
|
|
|
|
{ user: publicUser(account) },
|
|
|
|
|
|
sessionCookie(token, secureCookies),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path === '/api/auth/login' && req.method === 'POST') {
|
|
|
|
|
|
limits.check(`auth:${ip}`, 10, 15 * 60000);
|
|
|
|
|
|
const input = loginSchema.parse(await body(req));
|
|
|
|
|
|
const account = await store.userByEmail(input.email);
|
|
|
|
|
|
if (account?.pinFailures?.lockedUntil > Date.now()) {
|
|
|
|
|
|
send(429, {
|
|
|
|
|
|
error:
|
|
|
|
|
|
'Demasiados intentos. Espera 15 minutos para volver a probar.',
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (account?.password && !account.pinHash) {
|
|
|
|
|
|
send(409, {
|
|
|
|
|
|
error:
|
|
|
|
|
|
'Esta cuenta aún utiliza contraseña. Activa su PIN conservando tus partidas.',
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const valid = await verifyPin(
|
|
|
|
|
|
input.pin,
|
|
|
|
|
|
account?.pinHash ??
|
|
|
|
|
|
'00000000000000000000000000000000:' + '00'.repeat(64),
|
|
|
|
|
|
pepper,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!account || !valid) {
|
|
|
|
|
|
if (account)
|
|
|
|
|
|
await store.updateUser(account.id, (stored) => {
|
|
|
|
|
|
const failures = stored.pinFailures;
|
|
|
|
|
|
const count =
|
|
|
|
|
|
failures?.since > Date.now() - 900000 ? failures.count + 1 : 1;
|
|
|
|
|
|
stored.pinFailures = {
|
|
|
|
|
|
count,
|
|
|
|
|
|
since: count === 1 ? Date.now() : failures.since,
|
|
|
|
|
|
lockedUntil: count >= 5 ? Date.now() + 900000 : 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
send(401, { error: 'Correo o PIN incorrectos.' });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await store.updateUser(account.id, (stored) => {
|
|
|
|
|
|
delete stored.pinFailures;
|
|
|
|
|
|
stored.termsAcceptance = acceptanceRecord;
|
|
|
|
|
|
});
|
|
|
|
|
|
const token = await newSession(store, account.id);
|
|
|
|
|
|
send(
|
|
|
|
|
|
200,
|
|
|
|
|
|
{ user: publicUser(account) },
|
|
|
|
|
|
sessionCookie(token, secureCookies),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path === '/api/auth/migrate-pin' && req.method === 'POST') {
|
|
|
|
|
|
limits.check(`auth:${ip}`, 10, 900000);
|
|
|
|
|
|
const input = z
|
|
|
|
|
|
.object({
|
|
|
|
|
|
email: z.email().transform((v) => v.toLowerCase().trim()),
|
|
|
|
|
|
password: z.string().min(1).max(128),
|
|
|
|
|
|
pin: z.string().regex(/^\d{4}$/),
|
|
|
|
|
|
})
|
|
|
|
|
|
.parse(await body(req));
|
|
|
|
|
|
const account = await store.userByEmail(input.email);
|
|
|
|
|
|
if (
|
|
|
|
|
|
!account?.password ||
|
|
|
|
|
|
!(await verifyPassword(input.password, account.password))
|
|
|
|
|
|
) {
|
|
|
|
|
|
send(401, { error: 'Correo o contraseña anterior incorrectos.' });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const pinHash = await hashPin(input.pin, pepper);
|
|
|
|
|
|
const updated = await store.updateUser(account.id, (stored) => {
|
|
|
|
|
|
stored.pinHash = pinHash;
|
|
|
|
|
|
delete stored.password;
|
|
|
|
|
|
delete stored.pinFailures;
|
|
|
|
|
|
stored.termsAcceptance = acceptanceRecord;
|
|
|
|
|
|
});
|
|
|
|
|
|
await store.deleteUserSessions(account.id);
|
|
|
|
|
|
for (const socket of io.sockets.sockets.values())
|
|
|
|
|
|
if (socket.data.user.id === account.id) socket.disconnect(true);
|
|
|
|
|
|
const session = await newSession(store, account.id);
|
|
|
|
|
|
send(
|
|
|
|
|
|
200,
|
|
|
|
|
|
{ user: publicUser(updated) },
|
|
|
|
|
|
sessionCookie(session, secureCookies),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path === '/api/auth/guest' && req.method === 'POST') {
|
|
|
|
|
|
limits.check(`guest:${ip}`, 15, 900000);
|
|
|
|
|
|
const input = z
|
|
|
|
|
|
.object({
|
|
|
|
|
|
name: z.string().trim().min(2).max(30),
|
|
|
|
|
|
code: z
|
|
|
|
|
|
.string()
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.regex(/^[a-fA-F0-9]{12}$/)
|
|
|
|
|
|
.optional(),
|
|
|
|
|
|
})
|
|
|
|
|
|
.parse(await body(req));
|
|
|
|
|
|
if (input.code) {
|
|
|
|
|
|
const invitation = await platform.invitation(input.code);
|
|
|
|
|
|
if (!invitation.available)
|
|
|
|
|
|
throw new Error('Esta invitación ya no tiene plazas disponibles.');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (user) {
|
|
|
|
|
|
send(200, { user });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const id = randomUUID(),
|
|
|
|
|
|
prefix =
|
|
|
|
|
|
input.name
|
|
|
|
|
|
.normalize('NFKD')
|
|
|
|
|
|
.replace(/[^a-zA-Z0-9]/g, '')
|
|
|
|
|
|
.slice(0, 10) || 'Invitado';
|
|
|
|
|
|
const account = {
|
|
|
|
|
|
id,
|
|
|
|
|
|
name: input.name,
|
|
|
|
|
|
nick: `${prefix}_${id.slice(0, 6)}`,
|
|
|
|
|
|
email: null,
|
|
|
|
|
|
guest: true,
|
|
|
|
|
|
termsAcceptance: acceptanceRecord,
|
|
|
|
|
|
createdAt: new Date().toISOString(),
|
|
|
|
|
|
};
|
|
|
|
|
|
await store.createUser(account);
|
|
|
|
|
|
const guestToken = await newSession(store, id);
|
|
|
|
|
|
send(
|
|
|
|
|
|
201,
|
|
|
|
|
|
{ user: publicUser(account) },
|
|
|
|
|
|
sessionCookie(guestToken, secureCookies),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path === '/api/auth/logout' && req.method === 'POST') {
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
await store.deleteSession(hashToken(token));
|
|
|
|
|
|
for (const socket of io.sockets.sockets.values())
|
|
|
|
|
|
if (socket.data.token === token) socket.disconnect(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
send(200, { ok: true }, sessionCookie('', secureCookies));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path === '/api/rooms' && req.method === 'GET') {
|
|
|
|
|
|
send(200, { rooms: await platform.list(user?.id) });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path.startsWith('/api/invites/') && req.method === 'GET') {
|
|
|
|
|
|
const code = z
|
|
|
|
|
|
.string()
|
|
|
|
|
|
.regex(/^[a-fA-F0-9]{12}$/)
|
|
|
|
|
|
.parse(path.slice(13));
|
|
|
|
|
|
send(200, { invitation: await platform.invitation(code, user?.id) });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
|
send(401, { error: 'Inicia sesión para continuar.' });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path === '/api/rooms' && req.method === 'POST') {
|
|
|
|
|
|
const room = await platform.create(
|
|
|
|
|
|
user,
|
|
|
|
|
|
createSchema.parse(await body(req)),
|
|
|
|
|
|
);
|
|
|
|
|
|
changed();
|
|
|
|
|
|
send(201, { room: await platform.view(room.id, user.id) });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path === '/api/rooms/join' && req.method === 'POST') {
|
|
|
|
|
|
const input = z
|
|
|
|
|
|
.object({ code: z.string().trim().min(6).max(40) })
|
|
|
|
|
|
.parse(await body(req));
|
|
|
|
|
|
const room = await platform.join(user, input.code);
|
|
|
|
|
|
await broadcast(room.id);
|
|
|
|
|
|
changed();
|
|
|
|
|
|
send(200, { room: await platform.view(room.id, user.id) });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
/^\/api\/rooms\/[^/]+\/(leave|close-inactive)$/.test(path) &&
|
|
|
|
|
|
req.method === 'POST'
|
|
|
|
|
|
) {
|
|
|
|
|
|
const id = idSchema.parse(path.split('/')[3]);
|
|
|
|
|
|
const input = z
|
|
|
|
|
|
.object({ expectedVersion: z.number().int().nonnegative() })
|
|
|
|
|
|
.parse(await body(req));
|
|
|
|
|
|
if (path.endsWith('/leave'))
|
|
|
|
|
|
await platform.leave(user, id, input.expectedVersion);
|
|
|
|
|
|
else await platform.closeInactive(user, id, input.expectedVersion);
|
|
|
|
|
|
await broadcast(id);
|
|
|
|
|
|
changed();
|
|
|
|
|
|
send(200, { ok: true });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
path.match(/^\/api\/rooms\/[^/]+\/invitation$/) &&
|
|
|
|
|
|
req.method === 'POST'
|
|
|
|
|
|
) {
|
|
|
|
|
|
const id = idSchema.parse(path.split('/')[3]);
|
|
|
|
|
|
const input = z
|
|
|
|
|
|
.object({ message: z.string().trim().max(300) })
|
|
|
|
|
|
.parse(await body(req));
|
|
|
|
|
|
await platform.personalizeInvite(user, id, input.message);
|
|
|
|
|
|
send(200, { room: await platform.view(id, user.id) });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path.startsWith('/api/rooms/') && req.method === 'GET') {
|
|
|
|
|
|
const id = idSchema.parse(path.slice(11));
|
|
|
|
|
|
send(200, { room: await platform.view(id, user.id) });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path === '/api/profile' && req.method === 'GET') {
|
|
|
|
|
|
send(200, await platform.profile(user.id));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (path === '/api/profile/avatar' && req.method === 'POST') {
|
|
|
|
|
|
limits.check(`avatar:${user.id}`, 20, 60000);
|
|
|
|
|
|
const input = z
|
|
|
|
|
|
.union([
|
|
|
|
|
|
z.object({ preset: z.string().max(30).nullable() }),
|
|
|
|
|
|
z.object({ image: z.string().max(90000) }),
|
|
|
|
|
|
])
|
|
|
|
|
|
.parse(await body(req, 95000));
|
|
|
|
|
|
const avatar = validateAvatar(input);
|
|
|
|
|
|
const updated = publicUser(
|
|
|
|
|
|
await store.updateUser(user.id, (stored) => {
|
|
|
|
|
|
stored.avatar = avatar;
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
const rooms = (await store.listRooms(user.id)).filter((r) =>
|
|
|
|
|
|
r.players.some((p) => p.userId === user.id),
|
|
|
|
|
|
);
|
|
|
|
|
|
for (const room of rooms) {
|
|
|
|
|
|
await store.updateRoom(room.id, (stored) => {
|
|
|
|
|
|
stored.players.find((p) => p.userId === user.id).avatar =
|
|
|
|
|
|
updated.avatar;
|
|
|
|
|
|
});
|
|
|
|
|
|
await broadcast(room.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
changed();
|
|
|
|
|
|
send(200, { user: updated });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
/^\/api\/avatars\/[a-f0-9-]{36}$/.test(path) &&
|
|
|
|
|
|
req.method === 'GET'
|
|
|
|
|
|
) {
|
|
|
|
|
|
const account = await store.userById(path.split('/').at(-1));
|
|
|
|
|
|
if (!account?.avatar?.image) {
|
|
|
|
|
|
send(404, { error: 'Avatar no encontrado.' });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
res.writeHead(200, {
|
|
|
|
|
|
'content-type': 'image/png',
|
|
|
|
|
|
'cache-control': 'private, max-age=3600',
|
|
|
|
|
|
'x-content-type-options': 'nosniff',
|
|
|
|
|
|
});
|
|
|
|
|
|
res.end(Buffer.from(account.avatar.image.slice(22), 'base64'));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
send(404, { error: 'Ruta no encontrada.' });
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (e instanceof z.ZodError) {
|
|
|
|
|
|
send(400, {
|
|
|
|
|
|
error: req.url?.startsWith('/api/terms/')
|
|
|
|
|
|
? 'Debes aceptar expresamente la versión vigente de las condiciones.'
|
|
|
|
|
|
: 'Revisa los datos: correo válido, nick de 3–20 letras/números y PIN de cuatro cifras.',
|
|
|
|
|
|
fields: e.issues.map((i) => i.path.join('.')),
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if (e.code || /database|connect|query/i.test(e.message)) {
|
|
|
|
|
|
console.error('Error del servicio:', e.message);
|
|
|
|
|
|
send(500, { error: 'No se pudo completar la operación.' });
|
|
|
|
|
|
} else send(400, { error: e.message });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
|
|
|
server,
|
|
|
|
|
|
io,
|
|
|
|
|
|
platform,
|
|
|
|
|
|
close: async () => {
|
|
|
|
|
|
clearInterval(expiryTimer);
|
|
|
|
|
|
await sweepPromise;
|
|
|
|
|
|
return new Promise((resolve) => io.close(resolve));
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|