var process = require('process') var fs = require('fs') var path = require('path') // Handle SIGINT process.on('SIGINT', () => { console.info("SIGINT Received, exiting...") process.exit(0) }) // Handle SIGTERM process.on('SIGTERM', () => { console.info("SIGTERM Received, exiting...") process.exit(0) }) const parser = require('ua-parser-js'); const { uniqueNamesGenerator, animals, colors } = require('unique-names-generator'); class SnapdropServer { constructor(port) { const WebSocket = require('ws'); this._wss = new WebSocket.Server({ port: port }); this._wss.on('connection', (socket, request) => this._onConnection(new Peer(socket, request))); this._wss.on('headers', (headers, response) => this._onHeaders(headers, response)); this._rooms = {}; this._userIdIndex = {}; this._fingerprintIndex = {}; this._pairingRecords = {}; this._pendingPairRequests = {}; this._canvasRooms = {}; this._loadPairingRecords(); console.log('Snapdrop is running on port', port); } _loadPairingRecords() { const dataFile = path.join(__dirname, 'data', 'pairing_records.json'); try { if (fs.existsSync(dataFile)) { this._pairingRecords = JSON.parse(fs.readFileSync(dataFile, 'utf8')); } } catch (e) { console.error('Failed to load pairing records:', e.message); } } _savePairingRecords() { const dataDir = path.join(__dirname, 'data'); if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); const dataFile = path.join(dataDir, 'pairing_records.json'); try { fs.writeFileSync(dataFile, JSON.stringify(this._pairingRecords, null, 2)); } catch (e) { console.error('Failed to save pairing records:', e.message); } } _onConnection(peer) { this._joinRoom(peer); peer.socket.on('message', message => this._onMessage(peer, message)); peer.socket.on('error', console.error); this._keepAlive(peer); this._send(peer, { type: 'display-name', message: { displayName: peer.name.displayName, deviceName: peer.name.deviceName } }); } _onHeaders(headers, response) { if (response.headers.cookie && response.headers.cookie.indexOf('peerid=') > -1) return; response.peerId = Peer.uuid(); headers.push('Set-Cookie: peerid=' + response.peerId + "; SameSite=Strict; Secure"); } _onMessage(sender, message) { try { message = JSON.parse(message); } catch (e) { return; } switch (message.type) { case 'disconnect': this._leaveRoom(sender); break; case 'pong': sender.lastBeat = Date.now(); break; case 'register': this._handleRegister(sender, message); break; case 'discoverMyDevices': this._handleDiscoverMyDevices(sender, message); break; case 'transportNegotiate': this._handleTransportNegotiate(sender, message); break; case 'wsRelay': this._handleWsRelay(sender, message); break; case 'pair-request': case 'pairRequest': this._handlePairRequest(sender, message); break; case 'pair-accept': case 'pairAccept': this._handlePairAccept(sender, message); break; case 'pair-reject': case 'pairReject': this._handlePairReject(sender, message); break; case 'heartbeat': sender.lastBeat = Date.now(); break; case 'textMessage': this._handleTextMessage(sender, message); break; case 'fileMeta': this._handleFileMeta(sender, message); break; case 'canvas-stroke': this._handleCanvasStroke(sender, message); break; case 'canvas-cursor': this._handleCanvasCursor(sender, message); break; case 'canvas-join': this._handleCanvasJoin(sender, message); break; case 'canvas-leave': this._handleCanvasLeave(sender, message); break; case 'canvas-snapshot': this._handleCanvasSnapshot(sender, message); break; } const handledTypes = ['disconnect', 'pong', 'register', 'discoverMyDevices', 'transportNegotiate', 'wsRelay', 'pair-request', 'pairRequest', 'pair-accept', 'pairAccept', 'pair-reject', 'pairReject', 'heartbeat', 'textMessage', 'fileMeta', 'canvas-stroke', 'canvas-cursor', 'canvas-join', 'canvas-leave', 'canvas-snapshot']; if (message.to && !handledTypes.includes(message.type)) { let recipientId = message.to; let recipient = this._resolveTarget(sender, recipientId); if (recipient) { delete message.to; message.sender = sender.id; message.from = sender.fingerprint || sender.id; message.fromPeerId = sender.id; this._send(recipient, message); } else { console.log('Message routing failed: target not found for', recipientId, 'from', sender.id); } return; } } _handleRegister(sender, message) { const data = message.payload || message.data || {}; if (data.userId) { const oldUserId = sender.userId; sender.userId = data.userId; if (oldUserId && this._userIdIndex[oldUserId]) { this._userIdIndex[oldUserId].delete(sender.id); if (this._userIdIndex[oldUserId].size === 0) { delete this._userIdIndex[oldUserId]; } } if (!this._userIdIndex[sender.userId]) { this._userIdIndex[sender.userId] = new Set(); } this._userIdIndex[sender.userId].add(sender.id); } if (data.fingerprint) { const oldFingerprint = sender.fingerprint; sender.fingerprint = data.fingerprint; if (oldFingerprint && this._fingerprintIndex[oldFingerprint] === sender.id) { delete this._fingerprintIndex[oldFingerprint]; } const existingPeerId = this._fingerprintIndex[sender.fingerprint]; if (existingPeerId && existingPeerId !== sender.id) { const existingPeer = this._findPeer(existingPeerId); if (existingPeer) { console.log('Replacing old connection for fingerprint:', sender.fingerprint, 'old peer:', existingPeerId, 'new peer:', sender.id); this._leaveRoom(existingPeer); } } this._fingerprintIndex[sender.fingerprint] = sender.id; console.log('Register fingerprint mapping:', sender.fingerprint, '->', sender.id); } if (data.ipCity) sender.ipCity = data.ipCity; if (data.ipRange) sender.ipRange = data.ipRange; if (data.deviceModel) sender.deviceModel = data.deviceModel; if (data.platform) sender.platform = data.platform; if (data.alias) sender.name.displayName = data.alias; if (data.deviceType) sender.name.type = data.deviceType; this._send(sender, { type: 'registered', id: sender.id, fingerprint: sender.fingerprint || '', userId: sender.userId }); } _handleDiscoverMyDevices(sender, message) { const payload = message.payload || message.data || {}; const userId = payload.userId || message.userId || sender.userId; if (!userId) { this._send(sender, { type: 'myDevicesResponse', devices: [], error: 'userId required' }); return; } const seenFingerprints = new Map(); for (const roomId in this._rooms) { for (const peerId in this._rooms[roomId]) { const peer = this._rooms[roomId][peerId]; if (peer.id === sender.id) continue; if (peer.userId === userId) { const fp = peer.fingerprint || peer.id; if (!seenFingerprints.has(fp)) { seenFingerprints.set(fp, { id: peer.id, fingerprint: peer.fingerprint || '', alias: peer.name.displayName, deviceName: peer.name.deviceName, deviceModel: peer.deviceModel || '', platform: peer.platform || '', ip: peer.ip, ipCity: peer.ipCity || '', isOnline: true, rtcSupported: peer.rtcSupported }); } } } } this._send(sender, { type: 'myDevicesResponse', devices: Array.from(seenFingerprints.values()) }); } _handleTransportNegotiate(sender, message) { const targetId = message.to; if (!targetId) return; let targetPeer = this._findPeer(targetId); if (!targetPeer && this._fingerprintIndex[targetId]) { targetPeer = this._findPeer(this._fingerprintIndex[targetId]); } if (!targetPeer) { this._send(sender, { type: 'transportNegotiateResponse', success: false, error: 'Target device not found' }); return; } this._send(targetPeer, { type: 'transportNegotiate', from: sender.fingerprint || sender.id, transport: message.transport, payload: message.payload || {} }); } _handlePairRequest(sender, message) { const targetId = message.to; if (!targetId) return; let targetPeer = this._findPeer(targetId); if (!targetPeer && this._fingerprintIndex[targetId]) { targetPeer = this._findPeer(this._fingerprintIndex[targetId]); } if (!targetPeer) { this._send(sender, { type: 'pair-response', from: sender.id, success: false, error: 'Target device not found' }); return; } const requestId = Date.now().toString(36) + Math.random().toString(36).substr(2); this._pendingPairRequests[requestId] = { id: requestId, fromId: sender.id, toId: targetId, fingerprint: message.fingerprint || message.payload?.fingerprint || '', createdAt: Date.now(), expiresAt: Date.now() + 300000 }; this._send(targetPeer, { type: 'pair-request', from: sender.fingerprint || sender.id, requestId: requestId, fingerprint: this._pendingPairRequests[requestId].fingerprint, payload: message.payload || {} }); } _handlePairAccept(sender, message) { const requestId = message.requestId || message.payload?.requestId; if (!requestId || !this._pendingPairRequests[requestId]) { this._send(sender, { type: 'pair-response', success: false, error: 'Request not found or expired' }); return; } const req = this._pendingPairRequests[requestId]; delete this._pendingPairRequests[requestId]; const pairKey1 = req.fromId + ':' + sender.id; const pairKey2 = sender.id + ':' + req.fromId; this._pairingRecords[pairKey1] = { deviceId: req.fromId, peerDeviceId: sender.id, peerFingerprint: req.fingerprint, pairedAt: Date.now(), isTrusted: true }; this._pairingRecords[pairKey2] = { deviceId: sender.id, peerDeviceId: req.fromId, peerFingerprint: req.fingerprint, pairedAt: Date.now(), isTrusted: true }; this._savePairingRecords(); const fromPeer = this._findPeer(req.fromId); if (fromPeer) { this._send(fromPeer, { type: 'pair-response', from: sender.fingerprint || sender.id, success: true, requestId: requestId }); } this._send(sender, { type: 'pair-response', from: fromPeer ? (fromPeer.fingerprint || fromPeer.id) : req.fromId, success: true, requestId: requestId }); } _handlePairReject(sender, message) { const requestId = message.requestId || message.payload?.requestId; if (!requestId || !this._pendingPairRequests[requestId]) return; const req = this._pendingPairRequests[requestId]; delete this._pendingPairRequests[requestId]; const fromPeer = this._findPeer(req.fromId); if (fromPeer) { this._send(fromPeer, { type: 'pair-response', from: sender.fingerprint || sender.id, success: false, requestId: requestId, error: 'Rejected' }); } } _findPeer(peerId) { for (const roomId in this._rooms) { if (this._rooms[roomId][peerId]) { return this._rooms[roomId][peerId]; } } return null; } _resolveTarget(sender, targetId) { if (this._rooms[sender.ip] && this._rooms[sender.ip][targetId]) { return this._rooms[sender.ip][targetId]; } let recipient = this._findPeer(targetId); if (recipient) return recipient; if (this._fingerprintIndex[targetId]) { const mappedPeerId = this._fingerprintIndex[targetId]; console.log('Fingerprint mapping:', targetId, '->', mappedPeerId); recipient = this._findPeer(mappedPeerId); if (recipient) return recipient; } if (sender.userId) { const sameUserPeerIds = this._userIdIndex[sender.userId]; if (sameUserPeerIds) { for (const peerId of sameUserPeerIds) { if (peerId !== sender.id) { recipient = this._findPeer(peerId); if (recipient) return recipient; } } } } if (this._userIdIndex[targetId]) { for (const peerId of this._userIdIndex[targetId]) { recipient = this._findPeer(peerId); if (recipient) return recipient; } } return null; } _handleTextMessage(sender, message) { const targetId = message.to; if (!targetId) return; const recipient = this._resolveTarget(sender, targetId); if (recipient) { this._send(recipient, { type: 'textMessage', from: sender.fingerprint || sender.id, fromPeerId: sender.id, to: recipient.id, message: message.message || message.payload || {}, timestamp: message.timestamp || Date.now() }); } else { console.log('textMessage routing failed: target not found for', targetId, 'from', sender.id); } } _handleFileMeta(sender, message) { const targetId = message.to; if (!targetId) return; const recipient = this._resolveTarget(sender, targetId); if (recipient) { this._send(recipient, { type: 'fileMeta', from: sender.fingerprint || sender.id, fromPeerId: sender.id, to: recipient.id, file: message.file || message.payload || {}, timestamp: message.timestamp || Date.now() }); } else { console.log('fileMeta routing failed: target not found for', targetId, 'from', sender.id); } } _handleWsRelay(sender, message) { const targetId = message.to; if (!targetId) return; let targetPeer = null; for (const roomId in this._rooms) { if (this._rooms[roomId][targetId]) { targetPeer = this._rooms[roomId][targetId]; break; } } if (!targetPeer && this._fingerprintIndex[targetId]) { const mappedPeerId = this._fingerprintIndex[targetId]; for (const roomId in this._rooms) { if (this._rooms[roomId][mappedPeerId]) { targetPeer = this._rooms[roomId][mappedPeerId]; break; } } } if (!targetPeer && sender.userId) { const sameUserPeerIds = this._userIdIndex[sender.userId]; if (sameUserPeerIds) { for (const peerId of sameUserPeerIds) { if (peerId !== sender.id) { for (const roomId in this._rooms) { if (this._rooms[roomId][peerId]) { targetPeer = this._rooms[roomId][peerId]; break; } } if (targetPeer) break; } } } } if (!targetPeer) { this._send(sender, { type: 'wsRelay', from: sender.fingerprint || sender.id, payload: { relayType: 'error', data: { error: 'Target peer not found', targetId: targetId } } }); return; } const relayType = message.relayType || (message.payload && message.payload.relayType) || 'data'; const payload = message.payload || {}; if (!payload.relayType) { payload.relayType = relayType; } this._send(targetPeer, { type: 'wsRelay', from: sender.fingerprint || sender.id, payload: payload }); } _handleCanvasJoin(sender, message) { const payload = message.payload || {}; const canvasId = payload.canvasId; if (!canvasId) return; if (!this._canvasRooms[canvasId]) { this._canvasRooms[canvasId] = new Set(); } this._canvasRooms[canvasId].add(sender.id); sender.canvasRoomId = canvasId; const members = Array.from(this._canvasRooms[canvasId]); for (const peerId of this._canvasRooms[canvasId]) { if (peerId === sender.id) continue; const peer = this._findPeer(peerId); if (peer) { this._send(peer, { type: 'canvas-join', from: sender.fingerprint || sender.id, payload: { canvasId: canvasId, members: members } }); } } this._send(sender, { type: 'canvas-join', from: 'server', payload: { canvasId: canvasId, members: members } }); console.log('Canvas join:', sender.id, 'joined canvas', canvasId, 'members:', members.length); } _handleCanvasLeave(sender, message) { const payload = message.payload || {}; const canvasId = payload.canvasId || sender.canvasRoomId; if (!canvasId || !this._canvasRooms[canvasId]) return; this._canvasRooms[canvasId].delete(sender.id); sender.canvasRoomId = null; for (const peerId of this._canvasRooms[canvasId]) { const peer = this._findPeer(peerId); if (peer) { this._send(peer, { type: 'canvas-leave', from: sender.fingerprint || sender.id, payload: { canvasId: canvasId } }); } } if (this._canvasRooms[canvasId].size === 0) { delete this._canvasRooms[canvasId]; } console.log('Canvas leave:', sender.id, 'left canvas', canvasId); } _handleCanvasStroke(sender, message) { const payload = message.payload || {}; const canvasId = payload.canvasId || sender.canvasRoomId; if (!canvasId || !this._canvasRooms[canvasId]) return; for (const peerId of this._canvasRooms[canvasId]) { if (peerId === sender.id) continue; const peer = this._findPeer(peerId); if (peer) { this._send(peer, { type: 'canvas-stroke', from: sender.fingerprint || sender.id, payload: payload }); } } } _handleCanvasCursor(sender, message) { const payload = message.payload || {}; const canvasId = payload.canvasId || sender.canvasRoomId; if (!canvasId || !this._canvasRooms[canvasId]) return; for (const peerId of this._canvasRooms[canvasId]) { if (peerId === sender.id) continue; const peer = this._findPeer(peerId); if (peer) { this._send(peer, { type: 'canvas-cursor', from: sender.fingerprint || sender.id, payload: payload }); } } } _handleCanvasSnapshot(sender, message) { const payload = message.payload || {}; const canvasId = payload.canvasId || sender.canvasRoomId; const action = payload.action || 'request'; if (!canvasId || !this._canvasRooms[canvasId]) return; if (action === 'request') { const members = Array.from(this._canvasRooms[canvasId]); const otherMember = members.find(id => id !== sender.id); if (otherMember) { const peer = this._findPeer(otherMember); if (peer) { this._send(peer, { type: 'canvas-snapshot', from: sender.fingerprint || sender.id, payload: { canvasId: canvasId, action: 'request' } }); } } } else if (action === 'response') { const targetId = message.to; if (targetId) { const targetPeer = this._findPeer(targetId); if (targetPeer) { this._send(targetPeer, { type: 'canvas-snapshot', from: sender.fingerprint || sender.id, payload: payload }); } } } } _joinRoom(peer) { if (!this._rooms[peer.ip]) { this._rooms[peer.ip] = {}; } for (const otherPeerId in this._rooms[peer.ip]) { const otherPeer = this._rooms[peer.ip][otherPeerId]; this._send(otherPeer, { type: 'peer-joined', peer: peer.getInfo() }); } const otherPeers = []; for (const otherPeerId in this._rooms[peer.ip]) { otherPeers.push(this._rooms[peer.ip][otherPeerId].getInfo()); } this._send(peer, { type: 'peers', peers: otherPeers }); this._rooms[peer.ip][peer.id] = peer; if (peer.userId) { if (!this._userIdIndex[peer.userId]) { this._userIdIndex[peer.userId] = new Set(); } this._userIdIndex[peer.userId].add(peer.id); } } _leaveRoom(peer) { if (!this._rooms[peer.ip] || !this._rooms[peer.ip][peer.id]) return; this._cancelKeepAlive(this._rooms[peer.ip][peer.id]); if (peer.canvasRoomId && this._canvasRooms[peer.canvasRoomId]) { this._canvasRooms[peer.canvasRoomId].delete(peer.id); for (const peerId of this._canvasRooms[peer.canvasRoomId]) { const otherPeer = this._findPeer(peerId); if (otherPeer) { this._send(otherPeer, { type: 'canvas-leave', from: peer.fingerprint || peer.id, payload: { canvasId: peer.canvasRoomId } }); } } if (this._canvasRooms[peer.canvasRoomId].size === 0) { delete this._canvasRooms[peer.canvasRoomId]; } peer.canvasRoomId = null; } if (peer.fingerprint && this._fingerprintIndex[peer.fingerprint] === peer.id) { delete this._fingerprintIndex[peer.fingerprint]; } delete this._rooms[peer.ip][peer.id]; if (peer.userId && this._userIdIndex[peer.userId]) { this._userIdIndex[peer.userId].delete(peer.id); if (this._userIdIndex[peer.userId].size === 0) { delete this._userIdIndex[peer.userId]; } } peer.socket.terminate(); if (!Object.keys(this._rooms[peer.ip]).length) { delete this._rooms[peer.ip]; } else { for (const otherPeerId in this._rooms[peer.ip]) { const otherPeer = this._rooms[peer.ip][otherPeerId]; this._send(otherPeer, { type: 'peer-left', peerId: peer.id }); } } } _send(peer, message) { if (!peer) return; if (this._wss.readyState !== this._wss.OPEN) return; message = JSON.stringify(message); peer.socket.send(message, error => ''); } _keepAlive(peer) { this._cancelKeepAlive(peer); var timeout = 30000; if (!peer.lastBeat) { peer.lastBeat = Date.now(); } if (Date.now() - peer.lastBeat > 2 * timeout) { this._leaveRoom(peer); return; } this._send(peer, { type: 'ping' }); peer.timerId = setTimeout(() => this._keepAlive(peer), timeout); } _cancelKeepAlive(peer) { if (peer && peer.timerId) { clearTimeout(peer.timerId); } } } class Peer { constructor(socket, request) { this.socket = socket; this._setIP(request); this._setPeerId(request); this.rtcSupported = request.url.indexOf('webrtc') > -1; this._setName(request); this.userId = ''; this.fingerprint = ''; this.ipCity = ''; this.ipRange = ''; this.deviceModel = ''; this.platform = ''; this.canvasRoomId = null; this.timerId = 0; this.lastBeat = Date.now(); } _setIP(request) { if (request.headers['x-forwarded-for']) { this.ip = request.headers['x-forwarded-for'].split(/\s*,\s*/)[0]; } else { this.ip = request.connection.remoteAddress; } if (this.ip == '::1' || this.ip == '::ffff:127.0.0.1') { this.ip = '127.0.0.1'; } } _setPeerId(request) { if (request.peerId) { this.id = request.peerId; } else { this.id = request.headers.cookie.replace('peerid=', ''); } } toString() { return `` } _setName(req) { let ua = parser(req.headers['user-agent']); let deviceName = ''; if (ua.os && ua.os.name) { deviceName = ua.os.name.replace('Mac OS', 'Mac') + ' '; } if (ua.device.model) { deviceName += ua.device.model; } else { deviceName += ua.browser.name; } if(!deviceName) deviceName = 'Unknown Device'; const displayName = uniqueNamesGenerator({ length: 2, separator: ' ', dictionaries: [colors, animals], style: 'capital', seed: this.id.hashCode() }) this.name = { model: ua.device.model, os: ua.os.name, browser: ua.browser.name, type: ua.device.type, deviceName, displayName }; } getInfo() { return { id: this.id, name: this.name, rtcSupported: this.rtcSupported, userId: this.userId, ipCity: this.ipCity, deviceModel: this.deviceModel, platform: this.platform } } static uuid() { let uuid = '', ii; for (ii = 0; ii < 32; ii += 1) { switch (ii) { case 8: case 20: uuid += '-'; uuid += (Math.random() * 16 | 0).toString(16); break; case 12: uuid += '-'; uuid += '4'; break; case 16: uuid += '-'; uuid += (Math.random() * 4 | 8).toString(16); break; default: uuid += (Math.random() * 16 | 0).toString(16); } } return uuid; }; } Object.defineProperty(String.prototype, 'hashCode', { value: function() { var hash = 0, i, chr; for (i = 0; i < this.length; i++) { chr = this.charCodeAt(i); hash = ((hash << 5) - hash) + chr; hash |= 0; } return hash; } }); const server = new SnapdropServer(process.env.PORT || 3000);