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.

751 lines
25 KiB

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, termsReturnPath } 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, allowForm = false) {
const form =
allowForm &&
req.headers['content-type']?.startsWith(
'application/x-www-form-urlencoded',
);
if (!form && !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);
}
const text = Buffer.concat(chunks).toString('utf8');
if (form) return Object.fromEntries(new URLSearchParams(text));
try {
return JSON.parse(text);
} 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);
const nativeForm = req.headers['content-type']?.startsWith(
'application/x-www-form-urlencoded',
);
const input = await body(req, 4096, true);
z.object({
accepted: z.literal(true),
version: z.literal(terms.version),
}).parse(
nativeForm
? { ...input, accepted: input.accepted === 'true' }
: input,
);
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,
};
});
if (nativeForm) {
res.writeHead(303, {
location: termsReturnPath(input.returnTo, req.headers.origin),
'set-cookie': cookie,
'cache-control': 'no-store',
});
res.end();
} else 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.