Open for work
code/ nulled-room /server.js
261 lines · 7.9 KB · javascript Raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
const WebSocket = require('ws');
const http = require('http');
const crypto = require('crypto');

const PORT = 3000;
const INACTIVITY_CHECK = 30 * 1000; // check every 30s
const DEFAULT_INACTIVITY = 5 * 60 * 1000; // 5 min default
const MAX_INACTIVITY = 24 * 60 * 60 * 1000; // 24 hour max
const MAX_PAYLOAD = 25 * 1024 * 1024; // 25MB max message size

// roomId -> {
//   clients: Map<ws, {id, name, lastActive}>,
//   creatorId,
//   inactivityLimit,  // ms
//   nukeOnLeave       // bool
// }
const rooms = new Map();

const ADJECTIVES = [
  'null','void','dark','silent','ghost','lost','blind','cold',
  'dead','zero','blank','grey','static','empty','hollow','faded',
  'neon','rogue','stray','anon','hidden','masked','veiled','mute'
];
const NOUNS = [
  'node','signal','proxy','cipher','relay','packet','vector','daemon',
  'shell','byte','socket','kernel','loop','thread','stack','hash',
  'gate','root','shard','flux','drift','echo','pulse','trace'
];

function randomName() {
  const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
  const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
  const num = Math.floor(Math.random() * 99) + 1;
  return `${adj}_${noun}_${num}`;
}

function generateClientId() {
  return crypto.randomBytes(8).toString('hex');
}

function broadcast(room, message, excludeWs = null) {
  const data = JSON.stringify(message);
  for (const [ws] of room.clients) {
    if (ws !== excludeWs && ws.readyState === WebSocket.OPEN) {
      ws.send(data);
    }
  }
}

function sendTo(ws, message) {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify(message));
  }
}

function nukeRoom(roomId) {
  const room = rooms.get(roomId);
  if (!room) return;
  for (const [ws] of room.clients) {
    sendTo(ws, { type: 'nuked' });
    ws.close();
  }
  rooms.delete(roomId);
  console.log(`[nuke] room ${roomId} destroyed`);
}

function leaveRoom(roomId, ws) {
  const room = rooms.get(roomId);
  if (!room) return;
  const client = room.clients.get(ws);
  if (!client) return;

  const wasCreator = client.id === room.creatorId;
  room.clients.delete(ws);
  console.log(`[leave] ${client.name} left room ${roomId} (creator: ${wasCreator})`);

  // Nuke on host leave
  if (wasCreator && room.nukeOnLeave && room.clients.size > 0) {
    console.log(`[nuke-on-leave] creator left, nuking room ${roomId}`);
    nukeRoom(roomId);
    return;
  }

  broadcast(room, { type: 'system', text: `${client.name} disconnected.` });
  broadcast(room, { type: 'userlist', users: [...room.clients.values()].map(c => c.name) });

  if (room.clients.size === 0) {
    rooms.delete(roomId);
    console.log(`[cleanup] room ${roomId} empty, deleted`);
  }
}

// Inactivity checker
setInterval(() => {
  const now = Date.now();
  for (const [roomId, room] of rooms) {
    for (const [ws, client] of room.clients) {
      if (now - client.lastActive > room.inactivityLimit) {
        console.log(`[inactivity] kicking ${client.name} from ${roomId}`);
        sendTo(ws, { type: 'kicked', reason: 'inactivity' });
        leaveRoom(roomId, ws);
        ws.close();
      }
    }
  }
}, INACTIVITY_CHECK);

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('NulledRoom');
});

const wss = new WebSocket.Server({ server, maxPayload: MAX_PAYLOAD });

wss.on('connection', (ws) => {
  let currentRoomId = null;

  ws.on('message', (raw) => {
    let msg;
    try { msg = JSON.parse(raw); } catch { return; }

    // Update activity
    if (currentRoomId) {
      const room = rooms.get(currentRoomId);
      if (room && room.clients.has(ws)) {
        room.clients.get(ws).lastActive = Date.now();
      }
    }

    switch (msg.type) {

      case 'create': {
        const roomId = msg.roomId;
        if (!roomId || rooms.has(roomId)) {
          sendTo(ws, { type: 'error', text: 'Room already exists or invalid ID.' });
          return;
        }

        // Inactivity limit — clamp between 1 min and 24 hours
        let inactivityLimit = DEFAULT_INACTIVITY;
        if (msg.inactivityMinutes) {
          const mins = parseInt(msg.inactivityMinutes);
          if (!isNaN(mins)) {
            inactivityLimit = Math.min(Math.max(mins * 60 * 1000, 60000), MAX_INACTIVITY);
          }
        }

        const nukeOnLeave = msg.nukeOnLeave === true;
        const clientId = generateClientId();
        const name = randomName();

        rooms.set(roomId, {
          clients: new Map([[ws, { id: clientId, name, lastActive: Date.now() }]]),
          creatorId: clientId,
          inactivityLimit,
          nukeOnLeave
        });

        currentRoomId = roomId;
        sendTo(ws, { type: 'joined', roomId, name, clientId, isCreator: true, inactivityLimit, nukeOnLeave });
        console.log(`[create] room ${roomId} by ${name} | inactivity: ${inactivityLimit/60000}min | nukeOnLeave: ${nukeOnLeave}`);
        break;
      }

      case 'join': {
        const roomId = msg.roomId;
        const room = rooms.get(roomId);
        if (!room) {
          sendTo(ws, { type: 'error', text: 'Room not found.' });
          return;
        }
        const clientId = generateClientId();
        const name = randomName();
        room.clients.set(ws, { id: clientId, name, lastActive: Date.now() });
        currentRoomId = roomId;

        sendTo(ws, { type: 'joined', roomId, name, clientId, isCreator: false });

        broadcast(room, { type: 'system', text: `${name} connected.` }, ws);
        const userlist = [...room.clients.values()].map(c => c.name);
        broadcast(room, { type: 'userlist', users: userlist });
        sendTo(ws, { type: 'userlist', users: userlist });

        console.log(`[join] ${name} joined room ${roomId}`);
        break;
      }

      case 'message': {
        if (!currentRoomId) return;
        const room = rooms.get(currentRoomId);
        if (!room) return;
        const client = room.clients.get(ws);
        if (!client) return;
        // payload = encrypted blob (text or image), server never sees plaintext
        broadcast(room, {
          type: 'message',
          from: client.name,
          payload: msg.payload,
          isImage: msg.isImage === true // flag so receiver knows to render as image
        }, ws);
        break;
      }

      case 'nuke': {
        if (!currentRoomId) return;
        const room = rooms.get(currentRoomId);
        if (!room) return;
        const client = room.clients.get(ws);
        if (!client || client.id !== room.creatorId) {
          sendTo(ws, { type: 'error', text: 'Only the room creator can nuke.' });
          return;
        }
        console.log(`[nuke] ${client.name} nuked room ${currentRoomId}`);
        nukeRoom(currentRoomId);
        break;
      }

      case 'rejoin': {
        // Guest silently reconnecting — slide back in with same name, no announcement
        const roomId = msg.roomId;
        const room = rooms.get(roomId);
        if (!room) {
          sendTo(ws, { type: 'error', text: 'Room not found.' });
          return;
        }
        const clientId = generateClientId();
        const name = msg.name || randomName(); // keep same name if provided
        room.clients.set(ws, { id: clientId, name, lastActive: Date.now() });
        currentRoomId = roomId;

        sendTo(ws, { type: 'rejoined', roomId, name, clientId });

        // Update user list for everyone (including rejoiner)
        const userlist = [...room.clients.values()].map(c => c.name);
        broadcast(room, { type: 'userlist', users: userlist });
        sendTo(ws, { type: 'userlist', users: userlist });

        console.log(`[rejoin] ${name} silently rejoined room ${roomId}`);
        break;
      }

      case 'ping': {
        // keepalive, lastActive already updated above
        break;
      }
    }
  });

  ws.on('close', () => {
    if (currentRoomId) leaveRoom(currentRoomId, ws);
  });

  ws.on('error', (err) => {
    console.error('[ws error]', err.message);
  });
});

server.listen(PORT, () => {
  console.log(`NulledRoom server running on port ${PORT}`);
});