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