You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

729 lines
24 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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));
},
};
}

Powered by TurnKey Linux.