merimandi / server /index.js
datamk's picture
Upload 65 files
57da3ff verified
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
import db, { initDB } from './db.js';
// Import Http & Socket.io for Signaling
import { createServer } from 'http';
import { Server } from 'socket.io';
const app = express();
const PORT = process.env.PORT || 7860;
// Create raw HTTP server for Express app (required for socket.io)
const httpServer = createServer(app);
import * as nsfwjs from 'nsfwjs';
import sharp from 'sharp';
let tf;
try {
tf = await import('@tensorflow/tfjs-node');
} catch (e) {
tf = await import('@tensorflow/tfjs');
console.warn("⚠ Running TensorFlow.js in pure JS mode. Install @tensorflow/tfjs-node for faster performance.");
}
// Middlewares
import morgan from 'morgan';
app.use(cors());
app.use(express.json());
app.use(morgan('dev'));
const uploadDir = path.resolve('./server/uploads');
const modelsDir = path.resolve('./server/models');
// Graceful port handling – if the default port is busy, try the next one
// Ensure upload and model directories exist
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
if (!fs.existsSync(modelsDir)) {
fs.mkdirSync(modelsDir, { recursive: true });
}
// Graceful port handling – after httpServer is defined
httpServer.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.warn(`Port ${PORT} already in use, trying ${PORT + 1}`);
httpServer.listen(PORT + 1);
} else {
console.error('Server error:', err);
}
});
app.use('/uploads', express.static(uploadDir));
app.use('/models', express.static(modelsDir));
// Serve the React production build
const distDir = path.resolve('./dist');
if (fs.existsSync(distDir)) {
app.use(express.static(distDir));
}
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, uploadDir);
},
filename: function (req, file, cb) {
const ext = path.extname(file.originalname);
cb(null, uuidv4() + ext);
}
});
const upload = multer({ storage: storage });
// Load NSFW Model Locally
let nsfwModel = null;
const loadModel = async () => {
try {
// Wait for the server to be fully ready before internal fetch
await new Promise(resolve => setTimeout(resolve, 500));
// Attempt local filesystem URL first if path-aware, fallback to internal HTTP
// tfjs-node can handle file:// URLs directly and is MUCH faster.
const isNodeBackend = tf.engine().backendName === 'tensorflow';
let modelFileUrl;
if (isNodeBackend) {
// On Linux/HuggingFace, we need file:///app/server/...
// On Windows, absolute path needs to be converted carefully.
const absPath = path.resolve(modelsDir);
modelFileUrl = `file://${absPath.startsWith('/') ? '' : '/'}${absPath.replace(/\\/g, '/')}/`;
console.log("Loading NSFW Model via Native Engine (File IO)...");
} else {
modelFileUrl = `http://127.0.0.1:${PORT}/models/`;
console.log("Loading NSFW Model via Pure JS Mode (HTTP Loopback)...");
}
nsfwModel = await nsfwjs.load(modelFileUrl, { size: 224 });
console.log("✓ Offline NSFW Security Engine Loaded");
} catch (err) {
console.warn(`⚠ Local NSFW Model load failed (err: ${err.message}). Attempting CDN fallback...`);
try {
// Corrected CDN URL for MobileNetV2 fallback
nsfwModel = await nsfwjs.load('https://nsfwjs.com/model/', { size: 224 });
console.log("✓ Online NSFW Security Engine Loaded (CDN Fallback)");
} catch (cdnErr) {
console.warn("⚠ NSFW Model could not be loaded even from CDN. Content moderation will be disabled.", cdnErr.message);
}
}
};
// Helper to check if an image is inappropriate
const checkInappropriate = async (filePath) => {
if (!nsfwModel) return false;
try {
// Standardize image processing via sharp before passing to TFJS
// Model expects 224x224 (MobileNetV2 standard)
const { data, info } = await sharp(filePath)
.resize(224, 224)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
// Reconstruct into a tensor for classification
const image = tf.tensor3d(new Uint8Array(data), [info.height, info.width, 3]);
const predictions = await nsfwModel.classify(image);
image.dispose();
// Block if highly explicit content is detected
return predictions.some(p =>
['Porn', 'Hentai', 'Sexy'].includes(p.className) && p.probability > 0.6
);
} catch (err) {
console.error("AI Analysis Error:", err);
return false;
}
};
// REST API Endpoints
app.post('/api/users', upload.single('selfie'), async (req, res) => {
try {
const { id: deviceId, deviceToken, name, role, contact, nearestCity, district, state, pincode, location } = req.body;
if (!deviceId || !name || !role || !contact || !nearestCity || !district || !state || !pincode || !location || !req.file) {
if (req.file) try { fs.unlinkSync(req.file.path); } catch(e){}
return res.status(400).json({ error: 'Missing required fields, location or selfie.' });
}
// AI Validation on Backend
const isInappropriate = await checkInappropriate(req.file.path);
if (isInappropriate) {
try { fs.unlinkSync(req.file.path); } catch(e){}
return res.status(400).json({ error: 'Snapshot rejected: Inappropriate content detected by security engine.' });
}
const { rows: matches } = await db.query(`
SELECT * FROM users
WHERE contact = $1 AND role = $2 AND name = $3 AND state = $4 AND district = $5 AND pincode = $6
`, [contact, role, name, state, district, pincode]);
const existingProfile = matches[0];
const compoundId = existingProfile ? existingProfile.id : `${deviceId}_${role}`;
const selfiePath = `/uploads/${req.file.filename}`;
if (!existingProfile) {
// New Profile - Extra Security Check: Prevent hijacking if only phone matches but others don't
const { rows: phoneCheck } = await db.query(`SELECT id FROM users WHERE contact = $1 AND role = $2`, [contact, role]);
if (phoneCheck.length > 0) {
return res.status(400).json({ error: 'A profile with this phone number exists with different details. Please verify your exact name and location details to restore it.' });
}
await db.query(`
INSERT INTO users (id, name, role, "selfiePath", contact, "nearestCity", district, state, pincode, location, "deviceId", "deviceToken")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`, [compoundId, name, role, selfiePath, contact, nearestCity, district, state, pincode, location, deviceId, deviceToken]);
} else {
// "Auto-Sync" - Re-link existing profile to this new device if it matches contact/role
if (existingProfile.isBlocked) {
return res.status(403).json({ error: 'Account blocked by admin.' });
}
// Cleanup old selfie
if (existingProfile.selfiePath) {
try { fs.unlinkSync(path.join(process.cwd(), 'server', existingProfile.selfiePath)); } catch(e){}
}
await db.query(`
UPDATE users SET name = $1, "selfiePath" = $2, "nearestCity" = $3, district = $4, state = $5, pincode = $6, location = $7, "deviceId" = $8, "deviceToken" = $9
WHERE id = $10
`, [name, selfiePath, nearestCity, district, state, pincode, location, deviceId, deviceToken, compoundId]);
}
res.status(200).json({
id: compoundId, deviceId, name, role, contact,
nearestCity, district, state, pincode,
location: JSON.parse(location),
selfiePath, isBlocked: existingProfile ? existingProfile.isBlocked : 0
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to register.' });
}
});
app.get('/api/users/device/:deviceId', async (req, res) => {
try {
const { deviceId } = req.params;
const token = req.headers['x-device-token']; // Secure verification
const { rows: profiles } = await db.query(`
SELECT * FROM users
WHERE "deviceId" = $1 OR id LIKE $2
`, [deviceId, `${deviceId}_%`]);
// Simple Token-based security check (if profile exists and has a token, it MUST match)
const validProfiles = profiles.filter(p => !p.deviceToken || p.deviceToken === token);
res.json(validProfiles);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch device profiles' });
}
});
app.get('/api/listings', async (req, res) => {
try {
const { rows } = await db.query(`
SELECT listings.*, users."selfiePath" as "sellerSelfie"
FROM listings
JOIN users ON listings."sellerId" = users.id
WHERE listings.status != 'sold'
ORDER BY timestamp DESC
`);
const result = await Promise.all(rows.map(async (listing) => {
const { rows: images } = await db.query('SELECT "imagePath" FROM listing_images WHERE "listingId" = $1', [listing.id]);
return {
...listing,
location: JSON.parse(listing.location),
nearestCity: listing.nearestCity || '',
district: listing.district || '',
state: listing.state || '',
pincode: listing.pincode || '',
images: images.map(img => img.imagePath)
};
}));
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch' });
}
});
app.post('/api/listings', upload.array('images', 5), async (req, res) => {
try {
const { sellerId, sellerName, cropName, quantity, price, location, nearestCity, district, state, pincode } = req.body;
// Strict Location Validation
let parsedLocation;
try {
parsedLocation = JSON.parse(location);
if (!parsedLocation || typeof parsedLocation.lat !== 'number' || typeof parsedLocation.lng !== 'number') {
throw new Error('Invalid coordinates');
}
} catch (e) {
if (req.files) req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch(ev){} });
return res.status(400).json({ error: 'Valid GPS Location is strictly mandatory for all listings.' });
}
if (!sellerId || !cropName || !quantity || !location || !req.files || req.files.length === 0) {
if (req.files) req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch(e){} });
return res.status(400).json({ error: 'Missing fields' });
}
// AI Validation for all uploaded images
for (const file of req.files) {
const isInappropriate = await checkInappropriate(file.path);
if (isInappropriate) {
// Delete all uploaded images for this request if any are NSFW
req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch(e){} });
return res.status(400).json({ error: 'Listing rejected: One or more images contain inappropriate content.' });
}
}
const listingId = uuidv4();
const timestamp = Date.now();
await db.query(`
INSERT INTO listings (id, "sellerId", "sellerName", "cropName", quantity, price, location, "nearestCity", district, state, pincode, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`, [listingId, sellerId, sellerName, cropName, quantity, price, location, nearestCity, district, state, pincode, timestamp]);
const imagePaths = req.files.map(file => `/uploads/${file.filename}`);
await Promise.all(imagePaths.map(p =>
db.query(`INSERT INTO listing_images ("listingId", "imagePath") VALUES ($1, $2)`, [listingId, p])
));
const { rows: userRows } = await db.query(`SELECT "selfiePath" FROM users WHERE id = $1`, [sellerId]);
const user = userRows[0];
res.status(201).json({
id: listingId, sellerId, sellerName,
sellerSelfie: user ? user.selfiePath : null,
cropName, quantity, price, location: JSON.parse(location),
timestamp, images: imagePaths
});
} catch (err) {
if (req.files) req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch(e){} });
res.status(500).json({ error: 'Failed to create' });
}
});
// Configure Socket.IO Server
const io = new Server(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST']
}
});
// Global mapping of deviceId -> socketId
const activeUsers = new Map();
// Mark as sold endpoint (uses io)
app.patch('/api/listings/:id/sold', async (req, res) => {
try {
const { id } = req.params;
await db.query(`UPDATE listings SET status = 'sold' WHERE id = $1`, [id]);
// Broadcast everywhere immediately
io.emit('listing-updated', { id, status: 'sold' });
res.json({ success: true, id, status: 'sold' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to update listing' });
}
});
// Edit listing info endpoint
app.patch('/api/listings/:id/edit', async (req, res) => {
try {
const { id } = req.params;
const { cropName, quantity, price } = req.body;
await db.query(`UPDATE listings SET "cropName" = $1, quantity = $2, price = $3 WHERE id = $4`, [cropName, quantity, price, id]);
// Broadcast changes
io.emit('listing-edited', { id, cropName, quantity, price });
res.json({ success: true, id, cropName, quantity, price });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to edit listing' });
}
});
// --- USER MESSAGING API ---
app.post('/api/messages', async (req, res) => {
try {
const { id, senderId, receiverId, message, timestamp } = req.body;
const socketId = req.headers['x-socket-id'];
// Zero-Trust IDOR Check
const activeSocket = activeUsers.get(senderId ? senderId.split('_')[0] : null);
if (!activeSocket || activeSocket !== socketId) {
return res.status(403).json({ error: 'Messaging Identity verification failed.' });
}
await db.query(`INSERT INTO messages (id, "senderId", "receiverId", message, timestamp) VALUES ($1, $2, $3, $4, $5)`, [id, senderId, receiverId, message, timestamp]);
res.json({ success: true, id });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to send message' });
}
});
app.get('/api/messages/history/:user1/:user2', async (req, res) => {
try {
const { user1, user2 } = req.params;
const { rows: history } = await db.query(`
SELECT * FROM messages
WHERE ("senderId" = $1 AND "receiverId" = $2) OR ("senderId" = $3 AND "receiverId" = $4)
ORDER BY timestamp ASC
`, [user1, user2, user2, user1]);
res.json(history);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch messages' });
}
});
app.get('/api/messages/inbox/:userId', async (req, res) => {
try {
const { userId } = req.params;
// Get the most recent message for each unique conversation strictly ordered by timestamp
const { rows: inbox } = await db.query(`
SELECT * FROM (
SELECT m.*,
CASE WHEN m."senderId" = $1 THEN r.name ELSE s.name END as "contactName",
CASE WHEN m."senderId" = $1 THEN r."selfiePath" ELSE s."selfiePath" END as "contactSelfie",
CASE WHEN m."senderId" = $1 THEN r.id ELSE s.id END as "contactId",
ROW_NUMBER() OVER(PARTITION BY CASE WHEN m."senderId" = $1 THEN m."receiverId" ELSE m."senderId" END ORDER BY m.timestamp DESC) as rn
FROM messages m
JOIN users s ON m."senderId" = s.id
JOIN users r ON m."receiverId" = r.id
WHERE m."senderId" = $1 OR m."receiverId" = $1
) sub
WHERE rn = 1
ORDER BY timestamp DESC
`, [userId]);
res.json(inbox);
} catch (err) {
console.error("Inbox fetch err:", err);
res.status(500).json({ error: 'Failed to fetch inbox' });
}
});
app.get('/api/messages/unread-count/:userId', async (req, res) => {
try {
const { rows } = await db.query(`SELECT COUNT(*) as count FROM messages WHERE "receiverId" = $1 AND "isRead" = 0`, [req.params.userId]);
res.json({ count: parseInt(rows[0].count) });
} catch (err) {
res.status(500).json({ error: 'Failed to fetch unread count' });
}
});
app.patch('/api/messages/read/:senderId/:receiverId', async (req, res) => {
try {
await db.query(`UPDATE messages SET "isRead" = 1 WHERE "senderId" = $1 AND "receiverId" = $2`, [req.params.senderId, req.params.receiverId]);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed' });
}
});
// --- SUPPORT API (CLIENT) ---
app.post('/api/support', async (req, res) => {
try {
const { senderId, message } = req.body;
if (!senderId || !message) return res.status(400).json({ error: 'Missing fields' });
const ticketId = uuidv4();
await db.query(`INSERT INTO support_messages (id, "senderId", message, timestamp) VALUES ($1, $2, $3, $4)`, [ticketId, senderId, message, Date.now()]);
// Notify admin clients
io.emit('support-ticket-updated');
res.status(201).json({ success: true, id: ticketId });
} catch (err) {
res.status(500).json({ error: 'Failed to create support ticket' });
}
});
app.get('/api/support/history/:deviceId', async (req, res) => {
try {
const { deviceId } = req.params;
const { rows: history } = await db.query(`SELECT * FROM support_messages WHERE ("senderId" = $1 OR "senderId" LIKE $2) ORDER BY timestamp DESC`, [deviceId, `${deviceId}_%`]);
res.json(history);
} catch (err) {
res.status(500).json({ error: 'Failed' });
}
});
app.get('/api/support/unread/:deviceId', async (req, res) => {
try {
const { deviceId } = req.params;
const { rows } = await db.query(`SELECT COUNT(*) as count FROM support_messages WHERE ("senderId" = $1 OR "senderId" LIKE $2) AND "unreadAdminReply" = 1`, [deviceId, `${deviceId}_%`]);
res.json({ count: parseInt(rows[0].count) });
} catch (err) {
res.status(500).json({ error: 'Failed' });
}
});
app.patch('/api/support/read/:deviceId', async (req, res) => {
try {
const { deviceId } = req.params;
await db.query(`UPDATE support_messages SET "unreadAdminReply" = 0 WHERE ("senderId" = $1 OR "senderId" LIKE $2)`, [deviceId, `${deviceId}_%`]);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed' });
}
});
// --- ADMIN API ---
// WORKAROUND: Instead of storing secret in .env, we use the SHA-256 hash
// Hash of "MyNewSecureAdminPassword2026!" is provided as the default.
const DEFAULT_ADMIN_HASH = '922b11a4333a2f48c9cd3a55240b26b724d5273d28564e485582b5a375876e46';
const ADMIN_SECRET = process.env.ADMIN_SECRET;
const ADMIN_HASH_EXPECTED = ADMIN_SECRET
? crypto.createHash('sha256').update(ADMIN_SECRET).digest('hex')
: DEFAULT_ADMIN_HASH;
const activeAdminTokens = new Set();
app.post('/api/admin/login', (req, res) => {
if (req.body.passwordHash === ADMIN_HASH_EXPECTED) {
const sessionToken = uuidv4();
activeAdminTokens.add(sessionToken);
res.json({ token: sessionToken });
} else {
res.status(401).json({ error: 'Invalid encrypted admin password' });
}
});
const adminAuth = (req, res, next) => {
if (activeAdminTokens.has(req.headers['x-admin-key'])) return next();
return res.status(403).json({ error: 'Unauthorized Admin Session.' });
};
app.get('/api/admin/stats', adminAuth, async (req, res) => {
try {
const { rows: users } = await db.query(`SELECT count(*) as count FROM users`);
const { rows: listings } = await db.query(`SELECT count(*) as count FROM listings`);
const { rows: pendingSupport } = await db.query(`SELECT count(*) as count FROM support_messages WHERE "isResolved" = 0`);
res.json({ users: parseInt(users[0].count), listings: parseInt(listings[0].count), pendingSupport: parseInt(pendingSupport[0].count) });
} catch (e) { res.status(500).json({ error: 'Failed' }); }
});
app.get('/api/admin/users', adminAuth, async (req, res) => {
try {
const { rows: users } = await db.query(`SELECT * FROM users ORDER BY id ASC`);
res.json(users);
} catch(e) { res.status(500).json({ error: 'Failed' }); }
});
app.patch('/api/admin/users/:id/block', adminAuth, async (req, res) => {
const { isBlocked } = req.body;
try {
await db.query(`UPDATE users SET "isBlocked" = $1 WHERE id = $2`, [isBlocked ? 1 : 0, req.params.id]);
io.emit('user-blocked-status-changed', { id: req.params.id, isBlocked: isBlocked ? 1 : 0 }); // Global notification
res.json({ success: true });
} catch(e) { res.status(500).json({ error: 'Failed' }); }
});
app.delete('/api/admin/users/:id', adminAuth, async (req, res) => {
try {
const { rows: users } = await db.query(`SELECT * FROM users WHERE id = $1`, [req.params.id]);
const user = users[0];
if (!user) return res.status(404).json({ error: 'Not found' });
// Delete their selfie image file physically
try { fs.unlinkSync(path.join(uploadDir, path.basename(user.selfiePath))); } catch(e){}
// Also delete listing images as SQLite Pragma foreign key relies on delete cascade for db rows not files
const { rows: userListings } = await db.query(`SELECT id FROM listings WHERE "sellerId" = $1`, [req.params.id]);
for (const l of userListings) {
const { rows: imgs } = await db.query(`SELECT "imagePath" FROM listing_images WHERE "listingId" = $1`, [l.id]);
for (const i of imgs) {
try { fs.unlinkSync(path.join(uploadDir, path.basename(i.imagePath))); } catch(e){}
}
}
// Let's manually cascade for safety as PRAGMA might not be active on all connections
for (const l of userListings) {
await db.query(`DELETE FROM listing_images WHERE "listingId" = $1`, [l.id]);
}
await db.query(`DELETE FROM listings WHERE "sellerId" = $1`, [req.params.id]);
// Cleanup the user's selfie to prevent storage leak
if (user && user.selfiePath) {
try { fs.unlinkSync(path.join(process.cwd(), 'server', user.selfiePath)); } catch (e) {}
}
await db.query(`DELETE FROM users WHERE id = $1`, [req.params.id]);
// Blast it out to clients
io.emit('listing-updated', { forceRefresh: true }); // trigger refresh for others
res.json({ success: true });
} catch(e) { console.error(e); res.status(500).json({ error: 'Failed to delete' }); }
});
app.get('/api/admin/listings', adminAuth, async (req, res) => {
try {
const { rows: listings } = await db.query(`SELECT listings.*, users.name as "sellerNameDisplay", users.contact as "sellerContact" FROM listings JOIN users ON users.id = listings."sellerId" ORDER BY timestamp DESC`);
const result = await Promise.all(listings.map(async l => {
const { rows: imgs } = await db.query(`SELECT "imagePath" FROM listing_images WHERE "listingId" = $1`, [l.id]);
return { ...l, images: imgs.map(i => i.imagePath) };
}));
res.json(result);
} catch(e) { res.status(500).json({ error: 'Failed' }); }
});
app.delete('/api/admin/listings/:id', adminAuth, async (req, res) => {
try {
const { rows: imgs } = await db.query(`SELECT "imagePath" FROM listing_images WHERE "listingId" = $1`, [req.params.id]);
for (const i of imgs) {
try { fs.unlinkSync(path.join(uploadDir, path.basename(i.imagePath))); } catch(e){}
}
await db.query(`DELETE FROM listing_images WHERE "listingId" = $1`, [req.params.id]);
await db.query(`DELETE FROM listings WHERE id = $1`, [req.params.id]);
// Push delete command to all socket clients using the updated structure
io.emit('listing-updated', { id: req.params.id, status: 'deleted' });
res.json({ success: true });
} catch(e) { res.status(500).json({ error: 'Failed' }); }
});
app.get('/api/admin/support', adminAuth, async (req, res) => {
try {
const { rows: messages } = await db.query(`SELECT support_messages.*, users.name, users.role, users.contact, users."selfiePath" FROM support_messages LEFT JOIN users ON users.id = support_messages."senderId" ORDER BY support_messages.timestamp DESC`);
res.json(messages);
} catch(e) { res.status(500).json({ error: 'Failed' }); }
});
app.patch('/api/admin/support/:id/resolve', adminAuth, async (req, res) => {
try {
await db.query(`UPDATE support_messages SET "isResolved" = 1 WHERE id = $1`, [req.params.id]);
res.json({ success: true });
} catch(e) { res.status(500).json({ error: 'Failed' }); }
});
app.patch('/api/admin/support/:id/reply', adminAuth, async (req, res) => {
try {
const { reply } = req.body;
await db.query(`UPDATE support_messages SET "adminReply" = $1, "unreadAdminReply" = 1 WHERE id = $2`, [reply, req.params.id]);
io.emit('support-ticket-updated'); // Notify clients
res.json({ success: true, reply });
} catch(e) {
console.error("Support Reply Error:", e);
res.status(500).json({ error: 'Failed' });
}
});
// --- END ADMIN API ---
io.on('connection', (socket) => {
socket.on('join-network', (deviceId) => {
activeUsers.set(deviceId, socket.id);
console.log(`User connected: ${deviceId} via socket ${socket.id}`);
});
socket.on('disconnect', () => {
for (const [deviceId, sockId] of activeUsers.entries()) {
if (sockId === socket.id) {
activeUsers.delete(deviceId);
console.log(`User disconnected: ${deviceId}`);
break;
}
}
});
const getRawId = (id) => id ? id.split('_')[0] : null;
// Live Messaging Events
socket.on('send-message', (data) => {
const receiverSocket = activeUsers.get(getRawId(data.receiverId));
if (receiverSocket) {
io.to(receiverSocket).emit('receive-message', data);
}
});
// WebRTC Signaling Events
socket.on('call-user', (data) => {
const { userToCall, signalData, from, callerName, callerSelfie } = data;
const receiverSocket = activeUsers.get(getRawId(userToCall));
if (receiverSocket) {
io.to(receiverSocket).emit('incoming-call', {
signal: signalData,
from,
callerName,
callerSelfie
});
} else {
// Notify caller that receiver is offline
io.to(socket.id).emit('call-failed', { message: 'User is offline' });
}
});
socket.on('answer-call', (data) => {
const callerSocket = activeUsers.get(getRawId(data.to));
if (callerSocket) {
io.to(callerSocket).emit('call-accepted', data.signal);
}
});
socket.on('end-call', (data) => {
const peerSocket = activeUsers.get(getRawId(data.to));
if (peerSocket) {
io.to(peerSocket).emit('call-ended');
}
});
socket.on('missed-call', (data) => {
const peerSocket = activeUsers.get(getRawId(data.to));
if (peerSocket) io.to(peerSocket).emit('missed-call');
});
// Video Negotiation Signaling
socket.on('request-video', (data) => {
const peerSocket = activeUsers.get(getRawId(data.to));
if (peerSocket) io.to(peerSocket).emit('video-requested', { from: data.from });
});
socket.on('accept-video', (data) => {
const peerSocket = activeUsers.get(getRawId(data.to));
if (peerSocket) io.to(peerSocket).emit('video-accepted');
});
socket.on('reject-video', (data) => {
const peerSocket = activeUsers.get(getRawId(data.to));
if (peerSocket) io.to(peerSocket).emit('video-rejected');
});
});
// SPA fallback — must be registered AFTER all API routes but BEFORE listen
// Express 5 uses path-to-regexp v8 which requires '/{*splat}' instead of '*'
const spaDistDir = path.resolve('./dist');
const spaIndexHtml = path.join(spaDistDir, 'index.html');
if (fs.existsSync(spaIndexHtml)) {
app.get('/{*splat}', (req, res) => {
res.sendFile(spaIndexHtml);
});
}
httpServer.listen(PORT, '0.0.0.0', async () => {
await initDB();
console.log(`Backend API & WebSocket Server running at http://0.0.0.0:${PORT}`);
await loadModel();
});