const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const { MongoClient, ObjectId } = require('mongodb'); const axios = require('axios'); const PORT = process.env.PORT || 7860; const MONGO_URI = process.env.MONGO_URI || ''; const MONGO_DBNAME = process.env.MONGO_DBNAME || 'vibelyy'; async function main() { // MongoDB client const mongoClient = new MongoClient(MONGO_URI, { useUnifiedTopology: true }); await mongoClient.connect(); const db = mongoClient.db(MONGO_DBNAME); const blockedColl = db.collection('blocked'); // { blockerId, blockedId, at } const sessionsColl = db.collection('sessions'); // { peerA, peerB, startTime, endTime, locationA, locationB } const app = express(); const server = http.createServer(app); const io = new Server(server, { cors: { origin: '*' } }); // In-memory waiting list of lightweight objects: // { id, nickname, gender, blockedIds: [], location, socket } let waitingUsers = []; app.get('/health', (req, res) => res.send('ok')); // Helper: fetch approx location for an IP (returns "City, Country") async function getLocationForIp(ip) { try { // ipapi.co has no auth for basic lookup const res = await axios.get(`https://ipapi.co/${ip}/json/`, { timeout: 4000 }); const city = res.data.city || null; const country = res.data.country_name || res.data.country || null; if (city && country) return `${city}, ${country}`; if (country) return country; return 'Unknown'; } catch (e) { return 'Unknown'; } } // Find a match index in waitingUsers per preference: try opposite gender first function findMatchIndex(newUser) { // skip users that blocked newUser or are blocked by newUser const blockedSet = new Set((newUser.blockedIds || []).map(String)); // 1) try opposite gender let idx = waitingUsers.findIndex(u => u.id !== newUser.id && !blockedSet.has(String(u.id)) && !((u.blockedIds || []).map(String).includes(String(newUser.id))) && u.gender && newUser.gender && u.gender !== newUser.gender ); if (idx !== -1) return idx; // 2) fallback: any compatible user idx = waitingUsers.findIndex(u => u.id !== newUser.id && !blockedSet.has(String(u.id)) && !((u.blockedIds || []).map(String).includes(String(newUser.id))) ); return idx; } io.on('connection', (socket) => { console.log('ws connect', socket.id); // Data on this socket: socket.meta = { nickname, gender, blockedIds, location, peerId, sessionId } socket.meta = {}; // Text chat messages socket.on('message', ({ to, text, id } = {}) => { if (!to || !text) return; // Forward to peer (include id for receipts) io.to(to).emit('message', { from: socket.id, text, id }); // Acknowledge delivery to sender if (id) socket.emit('delivered', id); }); socket.on('seen', ({ to, id } = {}) => { if (!to || !id) return; io.to(to).emit('seen', id); }); socket.on('join', async (payload = {}) => { // payload: { nickname, gender, blockedIds } const nickname = (payload.nickname || 'Anon').slice(0, 64); const gender = payload.gender || ''; const blockedIds = Array.isArray(payload.blockedIds) ? payload.blockedIds.map(String) : []; // Attempt to get client IP (respect X-Forwarded-For if present) // let ip = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address || ''; // if (typeof ip === 'string' && ip.includes(',')) ip = ip.split(',')[0].trim(); const ip = payload.ip || socket.handshake.address || ''; console.log('join', socket.id, nickname, gender, blockedIds, ip); const location = await getLocationForIp(ip); // set meta socket.meta = { nickname, gender, blockedIds, location, peerId: null, sessionId: null }; // Build newUser object const newUser = { id: socket.id, nickname, gender, blockedIds, location, socket }; // Try to find match const idx = findMatchIndex(newUser); if (idx === -1) { // push to waiting waitingUsers.push(newUser); socket.emit('waiting'); console.log('waitingUsers add', socket.id); return; } // Pop matched user const peer = waitingUsers.splice(idx, 1)[0]; // mark each other's peerId socket.meta.peerId = peer.id; peer.socket.meta.peerId = socket.id; // Create session doc in DB const sessionDoc = { peerA: socket.id, peerB: peer.id, nicknameA: socket.meta.nickname, nicknameB: peer.nickname, startTime: new Date(), endTime: null, locationA: socket.meta.location, locationB: peer.location }; const r = await sessionsColl.insertOne(sessionDoc); const sessionId = r.insertedId.toString(); socket.meta.sessionId = sessionId; peer.socket.meta.sessionId = sessionId; // Inform both peers (include profile info and location) socket.emit('paired', { id: peer.id, nickname: peer.nickname, location: peer.location, sessionId }); peer.socket.emit('paired', { id: socket.id, nickname: socket.meta.nickname, location: socket.meta.location, sessionId }); // Set up session auto-end timer (30 minutes) const END_MS = 30 * 60 * 1000; setTimeout(async () => { try { // Double-check that session still exists and both sockets still connected and sessionId matches if (socket.meta.sessionId === sessionId) { // Notify both socket.emit('session_end', { reason: 'time_limit' }); } if (peer.socket && peer.socket.meta.sessionId === sessionId) { peer.socket.emit('session_end', { reason: 'time_limit' }); } // Update DB endTime await sessionsColl.updateOne({ _id: new ObjectId(sessionId) }, { $set: { endTime: new Date() } }); } catch (e) { console.warn('session end error', e); } }, END_MS); console.log('paired', socket.id, '↔', peer.id); }); // join // Signaling messages: forward to `to` socket.on('signal', ({ to, data } = {}) => { if (!to || !data) return; io.to(to).emit('signal', { from: socket.id, data }); }); // Typing indicator socket.on('typing', (to) => { if (!to) return; io.to(to).emit('typing'); }); // Report: store minimal info in sessions collection as a report array (optional) socket.on('report', async ({ peerId, reason } = {}) => { try { const doc = { reporter: socket.id, peerId: peerId || null, reason: reason || null, time: new Date() }; // Append to a `reports` array in sessions if session exists; else insert into `reports` collection if (socket.meta && socket.meta.sessionId) { await sessionsColl.updateOne({ _id: new ObjectId(socket.meta.sessionId) }, { $push: { reports: doc } }); } else { await db.collection('reports').insertOne(doc); } console.log('report saved', doc); } catch (e) { console.warn('report save failed', e); } }); // Block a peer: persist blocked relationship socket.on('block', async (blockedPeerId) => { if (!blockedPeerId) return; try { await blockedColl.insertOne({ blockerId: socket.id, blockedId: String(blockedPeerId), at: new Date() }); // Also update in-memory meta so matchmaking uses it if the client remains connected socket.meta.blockedIds = Array.from(new Set([...(socket.meta.blockedIds || []), String(blockedPeerId)])); console.log(`${socket.id} blocked ${blockedPeerId}`); } catch (e) { console.warn('block save failed', e); } }); // Leave current chat and try to rejoin (or just leave) socket.on('leave', async () => { try { const peerId = socket.meta.peerId; if (peerId) { // notify peer io.to(peerId).emit('peer_left'); // close session in DB if (socket.meta.sessionId) { await sessionsColl.updateOne({ _id: new ObjectId(socket.meta.sessionId) }, { $set: { endTime: new Date() } }); // clear session ids const sessId = socket.meta.sessionId; // clear session meta for both sides if connected socket.meta.sessionId = null; socket.meta.peerId = null; const s = io.sockets.sockets.get(peerId); if (s && s.meta) { s.meta.sessionId = null; s.meta.peerId = null; } } } else { // If user was waiting, remove from waiting list waitingUsers = waitingUsers.filter(u => u.id !== socket.id); } } catch (e) { console.warn('leave error', e); } }); socket.on('disconnect', async (reason) => { console.log('disconnect', socket.id, reason); // If user was in waitingUsers remove them waitingUsers = waitingUsers.filter(u => u.id !== socket.id); // If in a session, notify peer and update DB endTime if (socket.meta && socket.meta.peerId) { io.to(socket.meta.peerId).emit('peer_left'); } if (socket.meta && socket.meta.sessionId) { try { await sessionsColl.updateOne({ _id: new ObjectId(socket.meta.sessionId) }, { $set: { endTime: new Date() } }); } catch (e) { console.warn('session end on disconnect failed', e); } } }); }); // io.on(connection) server.listen(PORT, () => console.log(`Vibelyy backend listening on ${PORT}`)); } main().catch(err => { console.error('Fatal server error', err); process.exit(1); });