'use strict'; // Import necessary modules const dotenv = require('dotenv').config(); // Sentry (initialize before all other modules so it can instrument them) const Sentry = require('@sentry/node'); const sentryEnabled = process.env.SENTRY_ENABLED === 'true'; const sentryDsn = process.env.SENTRY_DSN; const sentryTracesSampleRate = parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE) || 0.5; if (sentryEnabled && sentryDsn && sentryDsn !== '') { Sentry.init({ dsn: sentryDsn, environment: process.env.NODE_ENV || 'development', tracesSampleRate: sentryTracesSampleRate, }); } const express = require('express'); const cors = require('cors'); const fs = require('fs'); const httpolyglot = require('httpolyglot'); const ngrok = require('@ngrok/ngrok'); const socketIO = require('socket.io'); const axios = require('axios'); const helmet = require('helmet'); const path = require('path'); const yaml = require('js-yaml'); const swaggerUi = require('swagger-ui-express'); const webpush = require('web-push'); const packageJson = require('../package.json'); // Logs const logs = require('./logs'); const log = new logs('server'); //log.error('Sentry test error', new Error('This is a test error to verify Sentry integration')); // Public directory location const PUBLIC_DIR = path.join(__dirname, '../', 'public'); // Home page/client const HOME = path.join(PUBLIC_DIR, '/index.html'); // Privacy page const PRIVACY = path.join(PUBLIC_DIR, '/privacy.html'); // Locales directory location const LOCALES_DIR = path.join(__dirname, 'locales'); function getAvailableLocales() { try { return fs .readdirSync(LOCALES_DIR, { withFileTypes: true }) .filter((dirent) => dirent.isFile() && dirent.name.endsWith('.json')) .map((dirent) => path.basename(dirent.name, '.json')) .filter(Boolean) .sort(); } catch (error) { log.warn('Unable to read locales directory', { directory: LOCALES_DIR, error: error.message, }); return []; } } // Map to store connected users const users = new Map(); // Map to store user media status (video/audio enabled/disabled) const userMediaStatus = new Map(); // Map to store push subscriptions (username -> PushSubscription[]) const pushSubscriptions = new Map(); // Configuration settings const config = { iceServers: [], stunServerEnabled: process.env.STUN_SERVER_ENABLED === 'true', stunServerUrl: process.env.STUN_SERVER_URL, turnServerEnabled: process.env.TURN_SERVER_ENABLED === 'true', turnServerUrl: process.env.TURN_SERVER_URL, turnServerUsername: process.env.TURN_SERVER_USERNAME, turnServerCredential: process.env.TURN_SERVER_CREDENTIAL, hostPasswordEnabled: process.env.HOST_PASSWORD_ENABLED === 'true', hostPassword: process.env.HOST_PASSWORD || '', apiKeySecret: process.env.API_KEY_SECRET, pushEnabled: process.env.PUSH_ENABLED === 'true', pushVapidPublicKey: process.env.PUSH_VAPID_PUBLIC_KEY || '', pushVapidPrivateKey: process.env.PUSH_VAPID_PRIVATE_KEY || '', pushVapidEmail: process.env.PUSH_VAPID_EMAIL || 'mailto:admin@example.com', randomImageUrl: process.env.RANDOM_IMAGE_URL || '', ringTimeout: parseInt(process.env.RINGING_TIMEOUT, 10) || 30, apiBasePath: '/api/v1', swaggerDocument: yaml.load(fs.readFileSync(path.join(__dirname, '/api/swagger.yaml'), 'utf8')), }; // If no room password is specified, a random one is generated (if room password is enabled) config.hostPassword = process.env.HOST_PASSWORD || (config.hostPasswordEnabled ? generatePassword() : ''); // Add STUN server if enabled and URL is provided if (config.stunServerEnabled && config.stunServerUrl) { config.iceServers.push({ urls: config.stunServerUrl }); } // Add TURN server if enabled and all required information is provided if (config.turnServerEnabled && config.turnServerUrl && config.turnServerUsername && config.turnServerCredential) { config.iceServers.push({ urls: config.turnServerUrl, username: config.turnServerUsername, credential: config.turnServerCredential, }); } // Configure Web Push if enabled if (config.pushEnabled && config.pushVapidPublicKey && config.pushVapidPrivateKey) { webpush.setVapidDetails(config.pushVapidEmail, config.pushVapidPublicKey, config.pushVapidPrivateKey); log.info('Web Push', { enabled: true }); } else if (config.pushEnabled) { log.warn('Web Push', { enabled: false, reason: 'VAPID keys not configured. Run: npm run generate-vapid-keys' }); config.pushEnabled = false; } const ngrokEnabled = process.env.NGROK_ENABLED === 'true'; const ngrokAuthToken = process.env.NGROK_AUTH_TOKEN; // Handle Cors const cors_origin = process.env.CORS_ORIGIN; const cors_methods = process.env.CORS_METHODS; let corsOrigin = '*'; let corsMethods = ['GET', 'POST']; if (cors_origin && cors_origin !== '*') { try { corsOrigin = JSON.parse(cors_origin); } catch (error) { log.error('Error parsing CORS_ORIGIN', error.message); } } if (cors_methods && cors_methods !== '') { try { corsMethods = JSON.parse(cors_methods); } catch (error) { log.error('Error parsing CORS_METHODS', error.message); } } const corsOptions = { origin: corsOrigin, methods: corsMethods, }; // Create Express application const app = express(); // Server configurations const port = process.env.PORT || 4000; const host = process.env.HOST || `http://localhost:${port}`; const apiDocs = host + config.apiBasePath + '/docs'; // Define paths to the SSL key and certificate files const keyPath = path.join(__dirname, 'ssl/key.pem'); const certPath = path.join(__dirname, 'ssl/cert.pem'); // Read SSL key and certificate files securely const options = { key: fs.readFileSync(keyPath, 'utf-8'), cert: fs.readFileSync(certPath, 'utf-8'), }; // Server both http and https const server = httpolyglot.createServer(options, app); // Create WebSocket server using Socket.io on top of HTTP server const io = socketIO(server); // Server config function getServerConfig(tunnelHttps = false) { return { ice: config.iceServers, host: { password_enabled: config.hostPasswordEnabled, password: config.hostPassword, }, api_key_secret: config.apiKeySecret, api_docs: apiDocs, running_at: tunnelHttps ? tunnelHttps : host, environment: process.env.NODE_ENV || 'development', appVersion: packageJson.version, nodeVersion: process.versions.node, }; } // Handle Ngrok async function ngrokStart() { try { await ngrok.authtoken(ngrokAuthToken); const listener = await ngrok.forward({ addr: port }); const tunnelUrl = listener.url(); log.info('Server config', getServerConfig(tunnelUrl)); } catch (err) { log.warn('Ngrok Start error', err); await ngrok.kill(); process.exit(1); } } // Configure Express middleware BEFORE starting the server app.use(cors(corsOptions)); // Handle cors options app.use(helmet.noSniff()); // Enable content type sniffing prevention app.use(express.static(PUBLIC_DIR)); // Serve static files from the 'public' directory app.use(express.json()); // Api parse body data as json app.use(config.apiBasePath + '/docs', swaggerUi.serve, swaggerUi.setup(config.swaggerDocument)); // api docs // Logs requests app.use((req, res, next) => { log.debug('New request', { //headers: req.headers, body: req.body, method: req.method, path: req.originalUrl, }); next(); }); // Set up route to serve the main HTML file app.get('/', (req, res) => { res.sendFile(HOME); }); // Serve the privacy policy page app.get('/privacy', (req, res) => { res.sendFile(PRIVACY); }); // Get Random Background Images app.get('/randomImage', async (req, res) => { if (config.randomImageUrl === '') return; // Keep client default bg image try { const response = await axios.get(config.randomImageUrl); const data = response.data; res.send(data); } catch (error) { log.error('Error fetching image', error.message); } }); // List available locales (derived from app/locales/*.json) app.get('/locales', (req, res) => { const locales = getAvailableLocales(); res.json({ locales: locales.length > 0 ? locales : ['en'] }); }); // Get translations for a specific language app.get('/translations/:locale', (req, res) => { try { const locale = String(req.params.locale || 'en').toLowerCase(); const validLocales = getAvailableLocales(); if (validLocales.length > 0 && !validLocales.includes(locale)) { return res.status(400).json({ error: 'Invalid locale' }); } const translationPath = path.join(LOCALES_DIR, `${locale}.json`); if (!fs.existsSync(translationPath)) { return res.status(400).json({ error: 'Invalid locale' }); } const translations = JSON.parse(fs.readFileSync(translationPath, 'utf8')); res.json({ locale: locale, translations: translations, }); } catch (error) { log.error('Error fetching translations', error.message); res.status(500).json({ error: 'Error loading translations' }); } }); // Direct Join room app.get('/join/', (req, res) => { if (Object.keys(req.query).length > 0) { log.debug('Request query', req.query); const { user, call, password } = req.query; // http://localhost:8000/join?user=user1 // http://localhost:8000/join?user=user2&call=user1 // http://localhost:8000/join?user=user1&password=123456789 // http://localhost:8000/join?user=user2&call=user1&password=123456789 if (config.hostPasswordEnabled && password !== config.hostPassword) { return unauthorized(res); } const isValidUser = isValidUsername(user); log.debug('isValidUser', { user: user, valid: isValidUser }); if (!isValidUser) { return unauthorized(res); } const isValidCall = call ? isValidUsername(call) : true; log.debug('isValidCall', { call: call, valid: isValidCall }); if (call && !isValidCall) { return unauthorized(res); } if (user || (user && call)) { return res.sendFile(HOME); } return notFound(res); } return notFound(res); }); app.get(`${config.apiBasePath}/connected`, (req, res) => { // Check if the user is authorized for this API call if (!isAuthorized(req)) { log.debug('Unauthorized API call: Get Connected', { headers: req.headers, body: req.body, }); return res.status(403).json({ error: 'Unauthorized!' }); } //log.debug(req.query); const { user } = req.query; if (!user) { return res.status(400).json({ error: 'User not provided in request query' }); } // Construct the base URL (simplified) const baseUrl = `${req.protocol}://${req.get('Host')}`; // Generate the password part dynamically based on hostPasswordEnabled const password = config.hostPasswordEnabled ? `&password=${config.hostPassword}` : ''; // Retrieve the list of connected users (ensure this returns an iterable like Map or Array) const users = getConnectedUsers(); if (!users || typeof users.values !== 'function') { return res.status(500).json({ error: 'Unable to retrieve connected users' }); } // Generate a list of user-to-call links for the provided user const connected = Array.from(users.values()).reduce((acc, connectedUser) => { if (user !== connectedUser) { acc.push(`${baseUrl}/join?user=${user}&call=${connectedUser}${password}`); } return acc; }, []); // Return the list of connected users that the provided user can call return res.json({ connected }); }); // Axios API requests app.get(`${config.apiBasePath}/users`, (req, res) => { // check if user is authorized for the API call if (!isAuthorized(req)) { log.debug('Unauthorized API call: Get Users', { headers: req.headers, body: req.body, }); return res.status(403).json({ error: 'Unauthorized!' }); } // Retrieve the list of connected users const users = getConnectedUsers(); return res.json({ users }); }); // Get VAPID public key for push subscription app.get(`${config.apiBasePath}/vapidPublicKey`, (req, res) => { if (!config.pushEnabled) { return res.json({ enabled: false }); } return res.json({ enabled: true, vapidPublicKey: config.pushVapidPublicKey }); }); // Handle push subscription update via REST (for service worker pushsubscriptionchange) app.post(`${config.apiBasePath}/pushSubscription`, express.json(), (req, res) => { if (!config.pushEnabled) { return res.status(400).json({ error: 'Push notifications not enabled' }); } const { subscription } = req.body; if (!subscription || !subscription.endpoint) { return res.status(400).json({ error: 'Invalid subscription' }); } // We can't reliably map this to a username from REST alone, // but we store it for resubscription change events log.debug('Push subscription updated via REST'); return res.json({ success: true }); }); // Check if Host password required app.get('/api/hostPassword', (req, res) => { const isPasswordRequired = config.hostPasswordEnabled; res.json({ isPasswordRequired }); }); // Check if Host password valid app.post('/api/hostPasswordValidate', (req, res) => { const { password } = req.body; const success = password === config.hostPassword; res.json({ success: success }); }); // Sentry error handler (must be registered before other error handlers) if (sentryEnabled && sentryDsn && sentryDsn !== '') { Sentry.setupExpressErrorHandler(app); } // Page not found app.use((req, res) => { return notFound(res); }); // Page not found function notFound(res) { res.json({ data: '404 not found' }); } // Unauthorized function unauthorized(res) { res.json({ data: '401 Unauthorized' }); } // Utility function to check API key authorization const isAuthorized = (req) => { const { authorization } = req.headers; return authorization === config.apiKeySecret; }; // Start the server and listen on the specified port server.listen(port, () => { if (ngrokEnabled && ngrokAuthToken) { ngrokStart(); } else { log.info('Server', getServerConfig()); } }); // Handle client errors (malformed/incomplete HTTP requests) gracefully server.on('clientError', (err, socket) => { const msg = err.code === 'HPE_HEADER_OVERFLOW' || err.message === 'Parse Error' ? 'Client HTTP parse error' : 'Client connection error'; log.warn(msg, { error: err.message, code: err.code }); if (socket && !socket.destroyed) { socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); } }); // Handle WebSocket connections io.on('connection', handleConnection); // Function to handle individual WebSocket connections function handleConnection(socket) { log.debug('User connected:', socket.id); // Refresh connected users broadcastConnectedUsers(); // Send a ping message to the newly connected client sendPing(socket); // Set up event listeners for incoming messages and disconnect socket.on('message', handleMessage); socket.on('disconnect', handleClose); // Function to handle incoming messages function handleMessage(data) { const { type } = data; log.debug('Received message', type); switch (type) { case 'signIn': handleSignIn(data); break; case 'offerAccept': case 'offerBusy': case 'offerDecline': case 'offerCreate': handleOffer(data); break; case 'offer': case 'answer': case 'candidate': case 'leave': case 'remoteAudio': case 'remoteVideo': handleSignalingMessage(data); break; case 'mediaStatus': handleMediaStatus(data); break; case 'chat': data.from = socket.username || 'Anonymous'; handleChatMessage(data); log.debug('Chat message:', data); break; case 'pushSubscription': handlePushSubscription(data); break; case 'pushUnsubscribe': handlePushUnsubscribe(data); break; case 'testPush': handleTestPush(); break; case 'pong': log.debug('Client response:', data.message); break; default: sendError(socket, `Unknown command: ${type}`); break; } } // Send a ping message to the newly connected client and iceServers for peer connection function sendPing(socket) { sendMsgTo(socket, { type: 'ping', message: 'Hello Client!', iceServers: config.iceServers, pushEnabled: config.pushEnabled, ringTimeout: config.ringTimeout, }); } // Function to handle push subscription registration function handlePushSubscription(data) { const { subscription } = data; const username = socket.username; if (!config.pushEnabled || !username || !subscription || !subscription.endpoint) { return; } // Store subscription (support multiple devices per user) const existing = pushSubscriptions.get(username) || []; // Replace if same endpoint exists, otherwise add const idx = existing.findIndex((s) => s.endpoint === subscription.endpoint); if (idx >= 0) { existing[idx] = subscription; } else { existing.push(subscription); } pushSubscriptions.set(username, existing); log.debug('Push subscription stored for', username, { devices: existing.length }); } // Function to handle push unsubscribe function handlePushUnsubscribe(data) { const { endpoint } = data; const username = socket.username; if (!username || !endpoint) return; const existing = pushSubscriptions.get(username) || []; const filtered = existing.filter((s) => s.endpoint !== endpoint); if (filtered.length > 0) { pushSubscriptions.set(username, filtered); } else { pushSubscriptions.delete(username); } log.debug('Push subscription removed for', username, { remaining: filtered.length }); } // Function to handle test push notification async function handleTestPush() { const username = socket.username; if (!config.pushEnabled || !username) return; const subscriptions = pushSubscriptions.get(username); if (!subscriptions || subscriptions.length === 0) { log.debug('No push subscriptions for test push', username); return; } const payload = JSON.stringify({ type: 'testPush', title: 'Call-me', body: 'Push notifications are working!', }); for (const sub of subscriptions) { try { await webpush.sendNotification(sub, payload); log.debug('Test push sent to', username); } catch (err) { log.warn('Test push failed for', username, { statusCode: err.statusCode, message: err.message }); } } } // Function to handle user sign-in request function handleSignIn(data) { const { name } = data; const isValidName = isValidUsername(name); log.debug('isValidName', { username: name, valid: isValidName }); if (!isValidName) { sendMsgTo(socket, { type: 'signIn', success: false, message: 'Invalid username.
Allowed letters, numbers, underscores, periods, hyphens, and @. Length: 3-36 characters.', }); return; } if (!users.has(name)) { users.set(name, socket); socket.username = name; // Initialize user media status (default: both enabled, no screen sharing) userMediaStatus.set(name, { video: true, audio: true, screenSharing: false, }); log.debug('User signed in:', name); sendMsgTo(socket, { type: 'signIn', success: true }); broadcastConnectedUsers(); } else { sendMsgTo(socket, { type: 'signIn', success: false, message: 'Username already in use' }); } } // Function to handle offer request function handleOffer(data) { log.debug('handleOffer', data); const { from, to, name, type } = data; const toName = type === 'offerAccept' ? to : from; const recipientSocket = users.get(toName); log.debug(`Handling offer for ${toName}`); switch (type) { case 'offerAccept': case 'offerCreate': if (recipientSocket) { // Include caller's media status when sending offer const callerMediaStatus = userMediaStatus.get(socket.username); const offerData = { ...data, callerMediaStatus: callerMediaStatus, }; sendMsgTo(recipientSocket, offerData); } else { // User is offline — try push notification if (type === 'offerAccept' && config.pushEnabled) { sendPushNotification(toName, socket.username).then((sent) => { if (sent) { sendMsgTo(socket, { type: 'pushSent', username: toName }); } else { sendMsgTo(socket, { type: 'notfound', username: toName }); } }); } else { log.warn(`Recipient (${toName}) not found`); sendMsgTo(socket, { type: 'notfound', username: toName }); } } break; case 'offerDecline': log.warn(`User ${name} declined your call`); if (recipientSocket) { sendMsgTo(recipientSocket, { type: 'offerDecline', from: name }); } break; case 'offerBusy': log.warn(`User ${name} busy in another call`); if (recipientSocket) { sendMsgTo(recipientSocket, { type: 'offerBusy', from: name }); } break; default: log.warn(`Unknown offer type: ${type}`); break; } } // Function to handle media status updates function handleMediaStatus(data) { const { video, audio, screenSharing } = data; const username = socket.username; if (username && userMediaStatus.has(username)) { const currentStatus = userMediaStatus.get(username); const newStatus = { video: video !== undefined ? video : currentStatus.video, audio: audio !== undefined ? audio : currentStatus.audio, screenSharing: screenSharing !== undefined ? screenSharing : currentStatus.screenSharing, }; userMediaStatus.set(username, newStatus); log.debug('Updated media status for', username, newStatus); // Broadcast screen sharing status change to other connected users if it changed if (screenSharing !== undefined && screenSharing !== currentStatus.screenSharing) { broadcastScreenSharingStatus(username, screenSharing); } } } // Function to broadcast screen sharing status to connected users function broadcastScreenSharingStatus(username, isScreenSharing) { const message = { type: 'remoteScreenShare', from: username, screenSharing: isScreenSharing, }; log.debug('Broadcasting screen sharing status:', message); // Send to all other connected users users.forEach((userSocket, userName) => { if (userName !== username) { sendMsgTo(userSocket, message); } }); } // Function to handle signaling messages (offer, answer, candidate, leave) function handleSignalingMessage(data) { const { type, name } = data; const recipientSocket = users.get(name); switch (type) { case 'leave': if (recipientSocket !== undefined) { log.debug('Leave room', socket.username); sendMsgTo(recipientSocket, { type: 'leave', name: socket.username }); } break; default: if (recipientSocket !== undefined) { sendMsgTo(recipientSocket, { ...data, name: socket.username }); } break; } } // Function to handle chat messages function handleChatMessage(data) { const { text, from } = data; log.debug('Chat message from', from, ':', text); // Broadcast the chat message to all connected clients broadcastMsgExpectSender(socket, { type: 'chat', from: from || 'Anonymous', text: text, timestamp: Date.now(), }); } // Function to handle the closing of a connection function handleClose() { const name = socket.username; if (name) { log.debug('User disconnected:', name); users.delete(name); userMediaStatus.delete(name); // Clean up media status broadcastConnectedUsers(); } } } // Allow letters, numbers, underscores, periods, hyphens, and @. Length: 3-36 characters function isValidUsername(username) { const usernamePattern = /^[a-zA-Z0-9_.-@]{3,36}$/; return usernamePattern.test(username); } // Send push notification to an offline user async function sendPushNotification(targetUsername, callerUsername) { const subscriptions = pushSubscriptions.get(targetUsername); if (!subscriptions || subscriptions.length === 0) { log.debug('No push subscription found for', targetUsername); return false; } const payload = JSON.stringify({ type: 'incomingCall', title: 'Call-me', body: `${callerUsername} is calling you`, caller: callerUsername, url: `/join?user=${encodeURIComponent(targetUsername)}&call=${encodeURIComponent(callerUsername)}`, }); let anySent = false; const invalidIndices = []; for (let i = 0; i < subscriptions.length; i++) { try { await webpush.sendNotification(subscriptions[i], payload); anySent = true; log.debug('Push notification sent to', targetUsername, { device: i + 1 }); } catch (err) { log.warn('Push notification failed for', targetUsername, { device: i + 1, statusCode: err.statusCode, message: err.message, }); // Remove expired/invalid subscriptions (410 Gone, 404 Not Found) if (err.statusCode === 410 || err.statusCode === 404) { invalidIndices.push(i); } } } // Clean up invalid subscriptions if (invalidIndices.length > 0) { const filtered = subscriptions.filter((_, idx) => !invalidIndices.includes(idx)); if (filtered.length > 0) { pushSubscriptions.set(targetUsername, filtered); } else { pushSubscriptions.delete(targetUsername); } } return anySent; } // Function to get all connected users function getConnectedUsers() { return Array.from(users.keys()); } // Function to broadcast all connected users function broadcastConnectedUsers() { const connectedUsers = getConnectedUsers(); log.debug('Connected Users', connectedUsers); broadcastMsg({ type: 'users', users: connectedUsers }); } // Function to broadcast a message to all connected clients function broadcastMsg(message) { log.debug('Broadcast message:', message); io.emit('message', message); } // Function to broadcast a message to all connected clients except the sender function broadcastMsgExpectSender(socket, message) { log.debug('Broadcast message:', message); socket.broadcast.emit('message', message); } // Function to send a message to a specific connection function sendMsgTo(socket, message) { log.debug('Sending message:', message.type); socket.emit('message', message); } // Function to send an error message to a specific connection function sendError(socket, message) { log.error('Error:', message); sendMsgTo(socket, { type: 'error', message: message }); } // Random password generator function generatePassword(length = 12) { const upperCase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; const lowerCase = 'abcdefghijklmnopqrstuvwxyz'; const numbers = '0123456789'; const allCharacters = upperCase + lowerCase + numbers; let password = ''; for (let i = 0; i < length; i++) { const randomIndex = Math.floor(Math.random() * allCharacters.length); password += allCharacters[randomIndex]; } return password; }