diff --git a/README.md b/README.md index 4602931..fc8c416 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,76 @@ PORT=8080 npm start Behavior: - If both files exist and you run `npm start`, Zap starts with HTTPS. -- Otherwise it falls back to HTTP. -- `npm run dev` and `npm run dev:lan` use HTTP. +- If cert files are missing, `npm start` fails closed (won't start). +- `npm run dev` and `npm run dev:lan` are still HTTP for local development. +- If you intentionally want insecure HTTP in start mode, use `ALLOW_INSECURE_HTTP=1 npm start` (or `node server.js --insecure-http`). Note: iOS Safari generally requires HTTPS for reliable WebRTC support. +### Create `certs/fullchain.pem` and `certs/privkey.pem` + +#### Local LAN workflow with `mkcert` (macOS) + +1. Install `mkcert`: + +```bash +brew install mkcert +``` + +2. Install and trust the local CA (this prompts for your macOS admin password): + +```bash +mkcert -install +``` + +3. Find your current LAN IP (use whichever interface is active): + +```bash +ipconfig getifaddr en0 || ipconfig getifaddr en1 +``` + +4. Generate cert/key files for localhost and that LAN IP: + +```bash +mkdir -p certs +mkcert -cert-file certs/fullchain.pem -key-file certs/privkey.pem localhost 127.0.0.1 ::1 +``` + +5. Start Zap in HTTPS mode: + +```bash +npm start +``` + +6. Verify TLS trust: + +```bash +curl -fsS https://localhost:3000 >/dev/null && echo "HTTPS trust OK" +``` + +Note: browser/curl trust checks are the right signal on macOS; Node.js TLS clients may still report trust errors unless explicitly configured to use system roots. + +If your machine's LAN IP changes, rerun step 4 with the new IP so the certificate SAN list stays valid. + +For a public DNS hostname, use Let's Encrypt: + +```bash +sudo certbot certonly --standalone -d your-hostname.example.com +mkdir -p certs +sudo cp /etc/letsencrypt/live/your-hostname.example.com/fullchain.pem certs/fullchain.pem +sudo cp /etc/letsencrypt/live/your-hostname.example.com/privkey.pem certs/privkey.pem +sudo chown "$(whoami)" certs/fullchain.pem certs/privkey.pem +chmod 600 certs/privkey.pem +``` + +## Signaling Hardening Options + +- `ZAP_JOIN_TOKEN`: Optional shared token for signaling/auth-protected LAN use. Start with a token and open Zap using `https://:3000/?token=`. +- `ZAP_WS_MAX_PAYLOAD`: Max signaling message size in bytes (default `262144`). +- `ZAP_WS_RATE_WINDOW_MS`: Rate-limit window for signaling messages (default `5000`). +- `ZAP_WS_RATE_MAX_MESSAGES`: Allowed messages per connection per window (default `120`). +- `ZAP_MAX_TRANSFER_BYTES`: Max accepted transfer size for metadata validation (default `2147483648`). + ## Usage ### Standard LAN Mode @@ -114,7 +179,7 @@ If signaling server connection fails, the app can switch to Hotspot Mode: ## Scripts -- `npm start`: Run server (uses HTTPS if certs are present) +- `npm start`: Run secure server (requires TLS certs unless you explicitly set `ALLOW_INSECURE_HTTP=1`) - `npm run dev`: Run local dev server bound to loopback (`127.0.0.1`) - `npm run dev:lan`: Run dev server bound to all interfaces (`0.0.0.0`) - `npm run check:syntax`: Syntax-check backend and frontend JS files diff --git a/public/app.js b/public/app.js index bd972b6..5e25221 100644 --- a/public/app.js +++ b/public/app.js @@ -67,8 +67,10 @@ const dropZone = $('#dropZone'); const fileInput = $('#fileInput'); const browseBtn = $('#browseBtn'); + const deviceBadge = $('#deviceBadge'); const deviceNameEl = $('#deviceName'); const renameBtn = $('#renameBtn'); + const reconnectBtn = $('#reconnectBtn'); const renameModal = $('#renameModal'); const renameInput = $('#renameInput'); const renameCancelBtn = $('#renameCancelBtn'); @@ -94,6 +96,14 @@ const shareUrlValue = $('#shareUrlValue'); const shareUrlCopyBtn = $('#shareUrlCopyBtn'); const shareUrlExtra = $('#shareUrlExtra'); + const chatMessagesEl = $('#chatMessages'); + const chatForm = $('#chatForm'); + const chatInput = $('#chatInput'); + const chatSendBtn = $('#chatSendBtn'); + const clipboardListEl = $('#clipboardList'); + const clipboardForm = $('#clipboardForm'); + const clipboardInput = $('#clipboardInput'); + const clipboardAddBtn = $('#clipboardAddBtn'); // Hotspot mode DOM refs const hotspotView = $('#hotspotView'); @@ -128,17 +138,48 @@ let hotspot = null; // HotspotSignaling instance when in hotspot mode let isOffline = false; let shareUrls = []; + const MAX_CHAT_ITEMS = 120; + const MAX_CLIPBOARD_ITEMS = 120; // ---- Signaling ---- const signaling = new SignalingClient(); const myDeviceType = detectDeviceType(); let myDeviceName = getDefaultDeviceName(); + const authToken = new URLSearchParams(location.search).get('token') || ''; - signaling.connect(myDeviceName, myDeviceType); + function setConnectionIndicatorState(state) { + if (deviceBadge) { + deviceBadge.classList.remove('state-connected', 'state-connecting', 'state-disconnected'); + deviceBadge.classList.add(`state-${state}`); + } + + const isConnected = state === 'connected'; + if (renameBtn) { + renameBtn.hidden = !isConnected; + } + + if (!isConnected && renameModal && !renameModal.hidden) { + renameModal.hidden = true; + } + } + + function setRefreshButtonVisible(visible) { + if (reconnectBtn) reconnectBtn.hidden = !visible; + } + + setConnectionIndicatorState('connecting'); + setRefreshButtonVisible(false); + + signaling.connect(myDeviceName, myDeviceType, authToken); signaling.on('registered', ({ id }) => { + isOffline = false; + setConnectionIndicatorState('connected'); + setRefreshButtonVisible(false); deviceNameEl.textContent = myDeviceName; + if (chatSendBtn) chatSendBtn.disabled = false; + if (clipboardAddBtn) clipboardAddBtn.disabled = false; }); signaling.on('peers', (peers) => { @@ -148,15 +189,46 @@ }); signaling.on('disconnected', () => { + setConnectionIndicatorState('connecting'); + setRefreshButtonVisible(false); deviceNameEl.textContent = 'Reconnecting...'; - // After a delay, if still not connected, offer hotspot mode - setTimeout(() => { - if (!signaling.myId || (signaling.ws && signaling.ws.readyState !== WebSocket.OPEN)) { - isOffline = true; - deviceNameEl.textContent = 'Offline'; - showView('hotspot'); - } - }, 4000); + if (chatSendBtn) chatSendBtn.disabled = true; + if (clipboardAddBtn) clipboardAddBtn.disabled = true; + }); + + signaling.on('reconnecting', ({ attempt, maxAttempts }) => { + isOffline = false; + setConnectionIndicatorState('connecting'); + setRefreshButtonVisible(false); + deviceNameEl.textContent = `Reconnecting ${attempt}/${maxAttempts}...`; + }); + + signaling.on('reconnect-exhausted', () => { + isOffline = true; + setConnectionIndicatorState('disconnected'); + setRefreshButtonVisible(true); + deviceNameEl.textContent = 'Offline'; + showView('hotspot'); + }); + + signaling.on('chat-message', (msg) => { + appendChatMessage(msg); + }); + + signaling.on('chat-delete', (msg) => { + removeChatMessage(msg.id); + }); + + signaling.on('clipboard-state', (msg) => { + replaceClipboardSnippets(msg.snippets); + }); + + signaling.on('clipboard-add', (msg) => { + appendClipboardSnippet(msg.snippet); + }); + + signaling.on('clipboard-delete', (msg) => { + removeClipboardSnippet(msg.id); }); // ---- Incoming signaling (callee side) ---- @@ -239,6 +311,7 @@ signaling.on('transfer-cancel', () => { if (fileSender) fileSender.cancel(); fileSender = null; + if (fileReceiver) fileReceiver.dispose(); fileReceiver = null; pendingIncomingData = []; if (peerConnection) peerConnection.close(); @@ -303,7 +376,10 @@ const unique = [...new Set((urls || []).filter(Boolean))]; const host = location.hostname; const isLocalhost = host === 'localhost' || host === '127.0.0.1' || host === '::1'; - const fallback = isLocalhost ? [] : [location.origin]; + const localShareUrl = authToken + ? `${location.origin}/?token=${encodeURIComponent(authToken)}` + : location.origin; + const fallback = isLocalhost ? [] : [localShareUrl]; shareUrls = unique.length > 0 ? unique : fallback; if (shareUrls.length === 0) { @@ -335,13 +411,255 @@ if (!shareUrlValue) return; try { - const resp = await fetch('/api/local-urls', { cache: 'no-store' }); + const endpoint = authToken + ? `/api/local-urls?token=${encodeURIComponent(authToken)}` + : '/api/local-urls'; + const resp = await fetch(endpoint, { cache: 'no-store' }); if (!resp.ok) throw new Error(`Request failed (${resp.status})`); const body = await resp.json(); renderShareUrls(Array.isArray(body.urls) ? body.urls : []); } catch { - renderShareUrls([location.origin]); + const localShareUrl = authToken + ? `${location.origin}/?token=${encodeURIComponent(authToken)}` + : location.origin; + renderShareUrls([localShareUrl]); + } + } + + // ---- Session chat ---- + + function renderChatEmptyState() { + if (!chatMessagesEl || chatMessagesEl.children.length > 0) return; + const empty = document.createElement('p'); + empty.className = 'chat-empty'; + empty.textContent = 'No messages yet. Start the conversation for this LAN session.'; + chatMessagesEl.appendChild(empty); + } + + function formatChatTime(ts) { + const date = new Date(ts); + if (Number.isNaN(date.getTime())) return '--:--'; + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + function resolveAuthorName(msg) { + if (typeof msg.name === 'string' && msg.name.trim()) return msg.name.trim(); + if (msg.from === signaling.myId) return myDeviceName; + const peer = peerList.find((p) => p.id === msg.from); + return peer ? peer.name : 'Unknown device'; + } + + function sanitizeListItemId(value) { + if (typeof value !== 'string') return null; + const id = value.trim(); + if (!id || id.length > 64) return null; + return id; + } + + function findChatMessageElementById(id) { + if (!chatMessagesEl) return null; + for (const child of chatMessagesEl.children) { + if (child.classList && child.classList.contains('chat-message') && child.dataset.chatId === id) { + return child; + } + } + return null; + } + + function appendChatMessage(msg) { + if (!chatMessagesEl || !msg || typeof msg.text !== 'string') return; + const text = msg.text.trim(); + if (!text) return; + const messageId = sanitizeListItemId(msg.id); + if (messageId && findChatMessageElementById(messageId)) return; + + const empty = chatMessagesEl.querySelector('.chat-empty'); + if (empty) empty.remove(); + + const messageEl = document.createElement('article'); + messageEl.className = 'chat-message'; + if (messageId) { + messageEl.dataset.chatId = messageId; + } + if (msg.from === signaling.myId) { + messageEl.classList.add('self'); + } + + const metaEl = document.createElement('div'); + metaEl.className = 'chat-meta'; + + const authorEl = document.createElement('span'); + authorEl.className = 'chat-author'; + authorEl.textContent = resolveAuthorName(msg); + + const timeEl = document.createElement('span'); + timeEl.className = 'chat-time'; + timeEl.textContent = formatChatTime(msg.ts || Date.now()); + + const textEl = document.createElement('p'); + textEl.className = 'chat-text'; + textEl.textContent = text; + + metaEl.appendChild(authorEl); + metaEl.appendChild(timeEl); + messageEl.appendChild(metaEl); + messageEl.appendChild(textEl); + + if (messageId && msg.from === signaling.myId) { + const actionsEl = document.createElement('div'); + actionsEl.className = 'chat-actions'; + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'chat-delete-btn'; + deleteBtn.textContent = 'Delete'; + deleteBtn.dataset.deleteChat = messageId; + deleteBtn.setAttribute('aria-label', 'Delete this message'); + + actionsEl.appendChild(deleteBtn); + messageEl.appendChild(actionsEl); + } + + chatMessagesEl.appendChild(messageEl); + + while (chatMessagesEl.children.length > MAX_CHAT_ITEMS) { + chatMessagesEl.removeChild(chatMessagesEl.firstElementChild); } + + chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; + } + + function removeChatMessage(rawId) { + if (!chatMessagesEl) return; + const id = sanitizeListItemId(rawId); + if (!id) return; + const messageEl = findChatMessageElementById(id); + if (!messageEl) return; + messageEl.remove(); + renderChatEmptyState(); + } + + function renderClipboardEmptyState() { + if (!clipboardListEl || clipboardListEl.children.length > 0) return; + const empty = document.createElement('p'); + empty.className = 'clipboard-empty'; + empty.textContent = 'No snippets yet. Add text for everyone to copy quickly.'; + clipboardListEl.appendChild(empty); + } + + function sanitizeSnippetForView(snippet) { + if (!snippet || typeof snippet !== 'object') return null; + if (typeof snippet.id !== 'string' || snippet.id.length > 64) return null; + if (typeof snippet.text !== 'string') return null; + const text = snippet.text.trim(); + if (!text || text.length > 800) return null; + + const name = typeof snippet.name === 'string' && snippet.name.trim() + ? snippet.name.trim().slice(0, 50) + : 'Unknown device'; + const ts = Number.isFinite(Number(snippet.ts)) ? Number(snippet.ts) : Date.now(); + + return { + id: snippet.id, + text, + name, + ts, + }; + } + + function createClipboardItemElement(snippet) { + const item = document.createElement('article'); + item.className = 'clipboard-item'; + item.dataset.snippetId = snippet.id; + + const header = document.createElement('div'); + header.className = 'clipboard-item-header'; + + const author = document.createElement('span'); + author.className = 'clipboard-author'; + author.textContent = snippet.name; + + const time = document.createElement('span'); + time.className = 'clipboard-time'; + time.textContent = formatChatTime(snippet.ts); + + const text = document.createElement('p'); + text.className = 'clipboard-text'; + text.textContent = snippet.text; + + const actions = document.createElement('div'); + actions.className = 'clipboard-actions'; + + const copyBtn = document.createElement('button'); + copyBtn.className = 'clipboard-copy-btn'; + copyBtn.type = 'button'; + copyBtn.textContent = 'Copy'; + copyBtn.dataset.copySnippet = snippet.id; + copyBtn.setAttribute('aria-label', 'Copy snippet text'); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'clipboard-delete-btn'; + deleteBtn.type = 'button'; + deleteBtn.textContent = 'Delete'; + deleteBtn.dataset.deleteSnippet = snippet.id; + deleteBtn.setAttribute('aria-label', 'Delete snippet'); + + actions.appendChild(copyBtn); + actions.appendChild(deleteBtn); + header.appendChild(author); + header.appendChild(time); + item.appendChild(header); + item.appendChild(text); + item.appendChild(actions); + return item; + } + + function findSnippetElementById(id) { + if (!clipboardListEl) return null; + for (const child of clipboardListEl.children) { + if (child.classList && child.classList.contains('clipboard-item') && child.dataset.snippetId === id) { + return child; + } + } + return null; + } + + function appendClipboardSnippet(rawSnippet) { + if (!clipboardListEl) return; + const snippet = sanitizeSnippetForView(rawSnippet); + if (!snippet) return; + if (findSnippetElementById(snippet.id)) return; + + const empty = clipboardListEl.querySelector('.clipboard-empty'); + if (empty) empty.remove(); + + clipboardListEl.appendChild(createClipboardItemElement(snippet)); + while (clipboardListEl.children.length > MAX_CLIPBOARD_ITEMS) { + clipboardListEl.removeChild(clipboardListEl.firstElementChild); + } + clipboardListEl.scrollTop = clipboardListEl.scrollHeight; + } + + function removeClipboardSnippet(rawId) { + if (!clipboardListEl) return; + const id = sanitizeListItemId(rawId); + if (!id) return; + const item = findSnippetElementById(id); + if (!item) return; + item.remove(); + renderClipboardEmptyState(); + } + + function replaceClipboardSnippets(rawSnippets) { + if (!clipboardListEl) return; + clipboardListEl.innerHTML = ''; + const snippets = Array.isArray(rawSnippets) ? rawSnippets : []; + for (const raw of snippets) { + const snippet = sanitizeSnippetForView(raw); + if (!snippet) continue; + clipboardListEl.appendChild(createClipboardItemElement(snippet)); + } + renderClipboardEmptyState(); } // ---- Render peers ---- @@ -372,7 +690,10 @@ if (shareUrlCopyBtn) { shareUrlCopyBtn.addEventListener('click', async () => { - const text = (shareUrls[0] || location.origin).trim(); + const localShareUrl = authToken + ? `${location.origin}/?token=${encodeURIComponent(authToken)}` + : location.origin; + const text = (shareUrls[0] || localShareUrl).trim(); try { await navigator.clipboard.writeText(text); @@ -387,15 +708,114 @@ }); } + if (chatForm && chatInput) { + renderChatEmptyState(); + if (chatSendBtn) chatSendBtn.disabled = true; + + chatForm.addEventListener('submit', (e) => { + e.preventDefault(); + const text = chatInput.value.trim(); + if (!text) return; + if (text.length > 400) { + alert('Chat messages are limited to 400 characters.'); + return; + } + + signaling.send({ + type: 'chat-message', + text, + }); + chatInput.value = ''; + chatInput.focus(); + }); + } + + if (chatMessagesEl) { + chatMessagesEl.addEventListener('click', (e) => { + const deleteBtn = e.target.closest('[data-delete-chat]'); + if (!deleteBtn) return; + const id = sanitizeListItemId(deleteBtn.dataset.deleteChat); + if (!id) return; + signaling.send({ + type: 'chat-delete', + id, + }); + }); + } + + if (clipboardForm && clipboardInput && clipboardListEl) { + renderClipboardEmptyState(); + if (clipboardAddBtn) clipboardAddBtn.disabled = true; + + clipboardForm.addEventListener('submit', (e) => { + e.preventDefault(); + const text = clipboardInput.value.trim(); + if (!text) return; + if (text.length > 800) { + alert('Clipboard snippets are limited to 800 characters.'); + return; + } + + signaling.send({ + type: 'clipboard-add', + text, + }); + + clipboardInput.value = ''; + clipboardInput.focus(); + }); + + clipboardListEl.addEventListener('click', async (e) => { + const deleteBtn = e.target.closest('[data-delete-snippet]'); + if (deleteBtn) { + const id = sanitizeListItemId(deleteBtn.dataset.deleteSnippet); + if (!id) return; + signaling.send({ + type: 'clipboard-delete', + id, + }); + return; + } + + const copyBtn = e.target.closest('[data-copy-snippet]'); + if (!copyBtn) return; + const item = copyBtn.closest('.clipboard-item'); + const textEl = item ? item.querySelector('.clipboard-text') : null; + const text = textEl ? textEl.textContent : ''; + if (!text) return; + + try { + await navigator.clipboard.writeText(text); + const original = copyBtn.textContent; + copyBtn.textContent = 'Copied'; + setTimeout(() => { + copyBtn.textContent = original; + }, 1000); + } catch { + window.prompt('Copy this snippet:', text); + } + }); + } + // ---- Rename ---- renameBtn.addEventListener('click', () => { - renameInput.value = deviceNameEl.textContent; + renameInput.value = myDeviceName; renameModal.hidden = false; renameInput.focus(); renameInput.select(); }); + if (reconnectBtn) { + reconnectBtn.addEventListener('click', () => { + isOffline = false; + setConnectionIndicatorState('connecting'); + setRefreshButtonVisible(false); + deviceNameEl.textContent = 'Reconnecting...'; + signaling.manualReconnect(); + }); + } + renameCancelBtn.addEventListener('click', () => { renameModal.hidden = true; }); @@ -496,6 +916,7 @@ fileReceiver = new FileReceiver(); fileReceiver.onProgress = updateReceiveProgress; fileReceiver.onComplete = onReceiveComplete; + fileReceiver.onError = onReceiveError; flushPendingIncomingData(); showTransferUI(pendingRequest.meta.name, pendingRequest.meta.size, 'Receiving'); @@ -531,7 +952,7 @@ fileSender = new FileSender(peerConnection, pendingFile); fileSender.onProgress = updateSendProgress; fileSender.onComplete = onSendComplete; - fileSender.onError = (err) => console.error('Send error:', err); + fileSender.onError = onSendError; fileSender.start(); pendingFile = null; } @@ -566,8 +987,33 @@ cleanupTransfer(); } + function returnToReadyView() { + const hotspotChannel = hotspot && hotspot.getDataChannel ? hotspot.getDataChannel() : null; + if (hotspotChannel && hotspotChannel.readyState === 'open') { + showView('hotspot'); + showHotspotSubview('connected'); + return; + } + showView('peers'); + } + + function onSendError(err) { + console.error('Send error:', err); + cleanupTransfer(); + returnToReadyView(); + alert('Transfer failed while sending. Please try again.'); + } + + function onReceiveError(err) { + console.error('Receive error:', err); + cleanupTransfer(); + returnToReadyView(); + alert('Transfer failed while receiving. Please ask the sender to retry.'); + } + function cleanupTransfer() { fileSender = null; + if (fileReceiver) fileReceiver.dispose(); fileReceiver = null; pendingIncomingData = []; // Delay closing the peer connection so the final file-complete message @@ -589,6 +1035,7 @@ peerConnection = null; } fileSender = null; + if (fileReceiver) fileReceiver.dispose(); fileReceiver = null; pendingIncomingData = []; pendingFile = null; @@ -679,6 +1126,7 @@ // Auto-create receiver on first data fileReceiver = new FileReceiver(); fileReceiver.onProgress = updateReceiveProgress; + fileReceiver.onError = onReceiveError; fileReceiver.onComplete = (info) => { onReceiveComplete(info); // Return to hotspot connected view after completion diff --git a/public/index.html b/public/index.html index f4c8d28..10e2b80 100644 --- a/public/index.html +++ b/public/index.html @@ -30,6 +30,7 @@

Zap

Connecting... + +
+ + + +
@@ -52,14 +64,6 @@

Zap

Searching for devices...

Open this URL on another device on the same network.

-
@@ -67,22 +71,66 @@

Searching for devices...

-
-
-
-
- - - - - +
+
+
+
+
+ + + + + +
+

Drop files here or

+

Select a device above, then send a file

-

Drop files here or

-

Select a device above, then send a file

+ +
+ +
- -
diff --git a/public/signaling.js b/public/signaling.js index 9906295..f2b3004 100644 --- a/public/signaling.js +++ b/public/signaling.js @@ -7,8 +7,12 @@ class SignalingClient { constructor() { this.ws = null; this.myId = null; + this.authToken = ''; this.reconnectDelay = 1000; this.maxReconnectDelay = 8000; + this.maxReconnectAttempts = 5; + this.reconnectAttempts = 0; + this.reconnectTimer = null; this.handlers = {}; } @@ -20,35 +24,44 @@ class SignalingClient { for (const fn of this.handlers[event] || []) fn(data); } - connect(deviceName, deviceType) { + connect(deviceName, deviceType, authToken = '') { this.deviceName = deviceName; this.deviceType = deviceType; + this.authToken = typeof authToken === 'string' ? authToken : ''; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const url = `${proto}//${location.host}`; - this.ws = new WebSocket(url); + const wsPath = this.authToken ? `/?token=${encodeURIComponent(this.authToken)}` : '/'; + const url = `${proto}//${location.host}${wsPath}`; + const ws = new WebSocket(url); + this.ws = ws; - this.ws.onopen = () => { + ws.onopen = () => { this.reconnectDelay = 1000; - this.ws.send(JSON.stringify({ + this.reconnectAttempts = 0; + ws.send(JSON.stringify({ type: 'register', name: deviceName, deviceType: deviceType, })); }; - this.ws.onmessage = (e) => { + ws.onmessage = (e) => { let msg; try { msg = JSON.parse(e.data); } catch { return; } this.handleMessage(msg); }; - this.ws.onclose = () => { + ws.onclose = () => { + if (this.ws === ws) this.ws = null; this.emit('disconnected'); this.scheduleReconnect(); }; - this.ws.onerror = () => { + ws.onerror = () => { // onclose fires after onerror }; } @@ -70,6 +83,11 @@ class SignalingClient { case 'file-accept': case 'file-decline': case 'transfer-cancel': + case 'chat-message': + case 'chat-delete': + case 'clipboard-state': + case 'clipboard-add': + case 'clipboard-delete': this.emit(msg.type, msg); break; } @@ -82,9 +100,37 @@ class SignalingClient { } scheduleReconnect() { - setTimeout(() => { - this.reconnectDelay = Math.min(this.reconnectDelay * 1.5, this.maxReconnectDelay); - this.connect(this.deviceName, this.deviceType); - }, this.reconnectDelay); + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + this.emit('reconnect-exhausted', { + attempts: this.reconnectAttempts, + maxAttempts: this.maxReconnectAttempts, + }); + return; + } + + const attempt = this.reconnectAttempts + 1; + const delayMs = this.reconnectDelay; + this.emit('reconnecting', { + attempt, + maxAttempts: this.maxReconnectAttempts, + delayMs, + }); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.reconnectAttempts = attempt; + this.reconnectDelay = Math.min(Math.round(this.reconnectDelay * 1.5), this.maxReconnectDelay); + this.connect(this.deviceName, this.deviceType, this.authToken); + }, delayMs); + } + + manualReconnect() { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.reconnectAttempts = 0; + this.reconnectDelay = 1000; + this.connect(this.deviceName, this.deviceType, this.authToken); } } diff --git a/public/style.css b/public/style.css index ba5db3a..1a8f95d 100644 --- a/public/style.css +++ b/public/style.css @@ -113,9 +113,34 @@ body { width: 8px; height: 8px; border-radius: 50%; + background: var(--danger); + box-shadow: 0 0 8px var(--danger-glow); +} + +.device-badge.state-connected .status-dot { background: var(--success); box-shadow: 0 0 8px var(--success-glow); - animation: pulse-dot 2s ease-in-out infinite; + animation: none; +} + +.device-badge.state-connecting .status-dot { + background: var(--success); + box-shadow: 0 0 8px var(--success-glow); + animation: pulse-dot 1.8s ease-in-out infinite; +} + +.device-badge.state-disconnected .status-dot { + background: var(--danger); + box-shadow: 0 0 8px var(--danger-glow); + animation: none; +} + +.device-badge.state-disconnected { + border-color: rgba(239, 68, 68, 0.5); +} + +.device-badge.state-connected { + border-color: rgba(16, 185, 129, 0.35); } @keyframes pulse-dot { @@ -136,17 +161,46 @@ body { color: var(--text); } +.rename-btn[hidden], +.reconnect-btn[hidden] { + display: none !important; +} + +.reconnect-btn { + all: unset; + cursor: pointer; + color: #fca5a5; + background: rgba(239, 68, 68, 0.16); + border: 1px solid rgba(239, 68, 68, 0.35); + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1; + padding: 5px 10px; + transition: background var(--transition), color var(--transition), transform var(--transition); +} + +.reconnect-btn:hover { + color: #fee2e2; + background: rgba(239, 68, 68, 0.24); + transform: translateY(-1px); +} + /* ---- Main ---- */ .main { flex: 1; + display: flex; + flex-direction: column; overflow-y: auto; position: relative; } .view { display: none; - height: 100%; + flex: 1; + min-height: 0; } .view.active { @@ -154,6 +208,18 @@ body { flex-direction: column; } +.persistent-share { + max-width: 800px; + width: 100%; + margin: 0 auto; + padding: 14px 20px 0; +} + +.persistent-share .share-url-card { + background: linear-gradient(165deg, rgba(99, 102, 241, 0.16), rgba(10, 10, 26, 0.82)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + /* ---- Lobby / Empty State ---- */ .empty-state { @@ -309,6 +375,17 @@ body { width: 100%; } +.session-workspace { + flex: 1; + min-height: 0; + display: flex; + gap: 16px; + max-width: 1100px; + width: 100%; + margin: 0 auto; + padding: 0 20px 20px; +} + .peer-card { background: var(--bg-surface); border: 1.5px solid var(--border); @@ -375,9 +452,6 @@ body { .drop-zone-container { flex: 1; - padding: 0 20px 20px; - max-width: 800px; - margin: 0 auto; width: 100%; min-height: 0; display: flex; @@ -439,6 +513,370 @@ body { opacity: 0.6; } +/* ---- Session Chat ---- */ + +.session-chat { + width: 320px; + flex-shrink: 0; + border-radius: var(--radius); + border: 1px solid var(--border); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(10, 10, 26, 0.78)); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.chat-header { + padding: 14px 14px 10px; + border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, rgba(34, 211, 238, 0.1), rgba(34, 211, 238, 0)); +} + +.chat-header h3 { + font-size: 14px; + font-weight: 700; + letter-spacing: 0.01em; + color: var(--text-bright); +} + +.chat-header p { + margin-top: 4px; + font-size: 12px; + color: var(--text-dim); + line-height: 1.4; +} + +.chat-messages { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.chat-empty { + margin: auto 0; + text-align: center; + color: var(--text-dim); + font-size: 12px; + line-height: 1.45; + padding: 10px 8px; +} + +.chat-message { + display: flex; + flex-direction: column; + gap: 4px; + padding: 9px 10px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + animation: chat-in 0.18s ease; +} + +@keyframes chat-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-message.self { + border-color: rgba(99, 102, 241, 0.4); + background: rgba(99, 102, 241, 0.15); +} + +.chat-meta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.chat-author { + color: var(--text-bright); + font-size: 12px; + font-weight: 600; +} + +.chat-time { + color: var(--text-dim); + font-size: 11px; + white-space: nowrap; +} + +.chat-text { + color: var(--text); + font-size: 13px; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.chat-actions { + display: flex; + justify-content: flex-end; +} + +.chat-delete-btn { + all: unset; + cursor: pointer; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + color: #fecaca; + background: rgba(239, 68, 68, 0.2); + border: 1px solid rgba(239, 68, 68, 0.35); + border-radius: 8px; + padding: 4px 8px; + transition: transform var(--transition), background var(--transition), color var(--transition); +} + +.chat-delete-btn:hover { + transform: translateY(-1px); + background: rgba(239, 68, 68, 0.3); + color: #fee2e2; +} + +.chat-form { + display: flex; + gap: 8px; + border-top: 1px solid var(--border); + padding: 10px; + background: rgba(10, 10, 26, 0.62); +} + +.chat-input { + flex: 1; + min-width: 0; + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(255, 255, 255, 0.04); + color: var(--text-bright); + font-size: 13px; + padding: 10px 12px; + outline: none; + font-family: var(--font); +} + +.chat-input:focus { + border-color: var(--border-active); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18); +} + +.chat-send-btn { + all: unset; + cursor: pointer; + flex-shrink: 0; + padding: 0 14px; + border-radius: 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + color: white; + background: linear-gradient(140deg, var(--primary), #4f46e5); + transition: transform var(--transition), box-shadow var(--transition), opacity var(--transition); +} + +.chat-send-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 14px var(--primary-glow); +} + +.chat-send-btn:disabled { + opacity: 0.55; + cursor: default; + transform: none; + box-shadow: none; +} + +/* ---- Session Clipboard ---- */ + +.clipboard-section { + border-top: 1px solid var(--border); + background: linear-gradient(180deg, rgba(16, 185, 129, 0.08), rgba(10, 10, 26, 0)); + display: flex; + flex-direction: column; + min-height: 0; + max-height: 330px; +} + +.clipboard-header { + padding: 12px 14px 8px; +} + +.clipboard-header h4 { + font-size: 13px; + font-weight: 700; + letter-spacing: 0.01em; + color: var(--text-bright); +} + +.clipboard-header p { + margin-top: 3px; + font-size: 11px; + color: var(--text-dim); + line-height: 1.35; +} + +.clipboard-list { + padding: 10px 12px; + min-height: 0; + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.clipboard-empty { + margin: auto 0; + font-size: 12px; + color: var(--text-dim); + text-align: center; + line-height: 1.45; +} + +.clipboard-item { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); + padding: 8px 9px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.clipboard-item-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.clipboard-author { + font-size: 11px; + font-weight: 600; + color: var(--text-bright); +} + +.clipboard-time { + font-size: 10px; + color: var(--text-dim); + white-space: nowrap; +} + +.clipboard-text { + font-size: 12px; + line-height: 1.4; + color: var(--text); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.clipboard-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.clipboard-copy-btn { + all: unset; + cursor: pointer; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.03em; + color: white; + background: linear-gradient(140deg, var(--success), #059669); + border-radius: 8px; + padding: 5px 9px; + transition: transform var(--transition), opacity var(--transition); +} + +.clipboard-copy-btn:hover { + transform: translateY(-1px); +} + +.clipboard-delete-btn { + all: unset; + cursor: pointer; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + color: #fecaca; + background: rgba(239, 68, 68, 0.2); + border: 1px solid rgba(239, 68, 68, 0.35); + border-radius: 8px; + padding: 5px 9px; + transition: transform var(--transition), background var(--transition), color var(--transition); +} + +.clipboard-delete-btn:hover { + transform: translateY(-1px); + background: rgba(239, 68, 68, 0.3); + color: #fee2e2; +} + +.clipboard-form { + display: flex; + flex-direction: column; + gap: 8px; + border-top: 1px solid var(--border); + padding: 10px; + background: rgba(10, 10, 26, 0.58); +} + +.clipboard-input { + width: 100%; + resize: vertical; + min-height: 64px; + max-height: 140px; + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(255, 255, 255, 0.04); + color: var(--text-bright); + font-size: 12px; + line-height: 1.4; + padding: 9px 10px; + font-family: var(--font); + outline: none; +} + +.clipboard-input:focus { + border-color: rgba(16, 185, 129, 0.6); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.16); +} + +.clipboard-add-btn { + all: unset; + cursor: pointer; + align-self: flex-end; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + color: white; + background: linear-gradient(145deg, #0f766e, #0ea5a6); + border-radius: 9px; + padding: 7px 11px; + transition: opacity var(--transition), transform var(--transition); +} + +.clipboard-add-btn:hover { + transform: translateY(-1px); +} + +.clipboard-add-btn:disabled { + opacity: 0.55; + cursor: default; + transform: none; +} + /* ---- Transfer ---- */ #transferView { @@ -1008,7 +1446,7 @@ body { } .drop-zone-container { - padding: 0 16px 16px; + min-height: 190px; } .transfer-card { @@ -1027,6 +1465,30 @@ body { .share-copy-btn { text-align: center; } + + .persistent-share { + padding: 10px 16px 0; + } + + .session-workspace { + padding: 0 16px 16px; + gap: 12px; + } +} + +@media (max-width: 920px) { + .session-workspace { + flex-direction: column; + } + + .session-chat { + width: 100%; + max-height: 580px; + } + + .clipboard-section { + max-height: 280px; + } } @media (max-width: 380px) { diff --git a/public/transfer.js b/public/transfer.js index cdf20d5..188dc8a 100644 --- a/public/transfer.js +++ b/public/transfer.js @@ -6,6 +6,8 @@ const CHUNK_SIZE = 64 * 1024; // 64 KB const BUFFER_THRESHOLD = 1 * 1024 * 1024; // 1 MB +const DEFAULT_MAX_RECEIVE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GB +const DEFAULT_TRANSFER_TIMEOUT_MS = 120000; class FileSender { constructor(peerConn, file) { @@ -131,13 +133,21 @@ class FileSender { class FileReceiver { - constructor() { + constructor(options = {}) { this.meta = null; this.chunks = []; this.bytesReceived = 0; this.startTime = 0; + this.timeoutId = null; + this.maxReceiveBytes = Number.isInteger(options.maxReceiveBytes) && options.maxReceiveBytes > 0 + ? options.maxReceiveBytes + : DEFAULT_MAX_RECEIVE_BYTES; + this.transferTimeoutMs = Number.isInteger(options.transferTimeoutMs) && options.transferTimeoutMs > 0 + ? options.transferTimeoutMs + : DEFAULT_TRANSFER_TIMEOUT_MS; this.onProgress = null; this.onComplete = null; + this.onError = null; } handleData(data) { @@ -147,14 +157,25 @@ class FileReceiver { try { msg = JSON.parse(data); } catch { return; } if (msg.type === 'file-meta') { - this.meta = msg; + const meta = this.validateMeta(msg); + if (!meta) { + this.fail(new Error('Invalid incoming file metadata')); + return; + } + this.meta = meta; this.chunks = []; this.bytesReceived = 0; this.startTime = performance.now(); + this.touchTimeout(); return; } if (msg.type === 'file-complete') { + if (!this.meta) return; + if (this.bytesReceived !== this.meta.size) { + this.fail(new Error('Incoming file ended before all bytes were received')); + return; + } this.assemble(); return; } @@ -163,15 +184,27 @@ class FileReceiver { // Binary chunk if (!this.meta) return; + const chunkLength = this.getChunkLength(data); + if (chunkLength === null) { + this.fail(new Error('Incoming transfer sent an unsupported chunk type')); + return; + } + const nextBytes = this.bytesReceived + chunkLength; + if (nextBytes > this.meta.size || nextBytes > this.maxReceiveBytes) { + this.fail(new Error('Incoming transfer exceeded expected size')); + return; + } + this.chunks.push(data); - this.bytesReceived += data.byteLength; + this.bytesReceived = nextBytes; + this.touchTimeout(); if (this.onProgress) { - const elapsed = (performance.now() - this.startTime) / 1000; + const elapsed = Math.max((performance.now() - this.startTime) / 1000, 0.001); const speed = this.bytesReceived / elapsed; const remaining = (this.meta.size - this.bytesReceived) / speed; this.onProgress({ - percent: this.bytesReceived / this.meta.size, + percent: this.meta.size === 0 ? 1 : this.bytesReceived / this.meta.size, bytesReceived: this.bytesReceived, totalBytes: this.meta.size, speed, @@ -180,12 +213,80 @@ class FileReceiver { } } - assemble() { - const blob = new Blob(this.chunks, { type: this.meta.mimeType || 'application/octet-stream' }); - this.triggerDownload(blob, this.meta.name); - if (this.onComplete) this.onComplete({ name: this.meta.name, size: this.meta.size }); + validateMeta(msg) { + if (!msg || typeof msg.name !== 'string') return null; + + const name = msg.name.trim().slice(0, 255); + if (!name) return null; + + const size = Number(msg.size); + if (!Number.isInteger(size) || size < 0 || size > this.maxReceiveBytes) return null; + + let mimeType = ''; + if (typeof msg.mimeType === 'string') { + mimeType = msg.mimeType.slice(0, 128); + } + + return { + name, + size, + mimeType, + }; + } + + getChunkLength(data) { + if (data instanceof ArrayBuffer) return data.byteLength; + if (ArrayBuffer.isView(data)) return data.byteLength; + if (typeof Blob !== 'undefined' && data instanceof Blob) return data.size; + return null; + } + + touchTimeout() { + if (!this.transferTimeoutMs) return; + if (this.timeoutId) clearTimeout(this.timeoutId); + this.timeoutId = setTimeout(() => { + this.fail(new Error('Incoming transfer timed out')); + }, this.transferTimeoutMs); + } + + clearTimeoutGuard() { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + } + + resetTransferState() { + this.clearTimeoutGuard(); this.meta = null; this.chunks = []; + this.bytesReceived = 0; + this.startTime = 0; + } + + fail(err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.resetTransferState(); + if (this.onError) this.onError(error); + } + + dispose() { + this.resetTransferState(); + this.onProgress = null; + this.onComplete = null; + this.onError = null; + } + + assemble() { + try { + const info = { name: this.meta.name, size: this.meta.size }; + const blob = new Blob(this.chunks, { type: this.meta.mimeType || 'application/octet-stream' }); + this.triggerDownload(blob, this.meta.name); + this.resetTransferState(); + if (this.onComplete) this.onComplete(info); + } catch (err) { + this.fail(err); + } } triggerDownload(blob, filename) { diff --git a/server.js b/server.js index e7de6dc..65cfb96 100644 --- a/server.js +++ b/server.js @@ -2,13 +2,60 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const express = require('express'); -const { WebSocketServer } = require('ws'); +const { WebSocketServer, WebSocket } = require('ws'); const isDev = process.argv.includes('--dev'); +const allowInsecureHttp = process.argv.includes('--insecure-http') || process.env.ALLOW_INSECURE_HTTP === '1'; const PORT = parseInt(process.env.PORT || '3000', 10); const HOST = process.env.HOST || (isDev ? '127.0.0.1' : '0.0.0.0'); +const wsJoinToken = (process.env.ZAP_JOIN_TOKEN || '').trim(); + +function parsePositiveInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +const MAX_WS_PAYLOAD = parsePositiveInt(process.env.ZAP_WS_MAX_PAYLOAD, 256 * 1024); +const WS_RATE_LIMIT_WINDOW_MS = parsePositiveInt(process.env.ZAP_WS_RATE_WINDOW_MS, 5000); +const WS_RATE_LIMIT_MAX_MESSAGES = parsePositiveInt(process.env.ZAP_WS_RATE_MAX_MESSAGES, 120); +const MAX_DEVICE_NAME_LENGTH = parsePositiveInt(process.env.ZAP_MAX_DEVICE_NAME_LENGTH, 30); +const MAX_FILE_NAME_LENGTH = parsePositiveInt(process.env.ZAP_MAX_FILE_NAME_LENGTH, 255); +const MAX_TRANSFER_BYTES = parsePositiveInt(process.env.ZAP_MAX_TRANSFER_BYTES, 2 * 1024 * 1024 * 1024); +const MAX_SDP_LENGTH = parsePositiveInt(process.env.ZAP_MAX_SDP_LENGTH, 64 * 1024); +const MAX_ICE_CANDIDATE_LENGTH = parsePositiveInt(process.env.ZAP_MAX_ICE_CANDIDATE_LENGTH, 8192); +const MAX_CHAT_MESSAGE_LENGTH = parsePositiveInt(process.env.ZAP_MAX_CHAT_MESSAGE_LENGTH, 400); +const MAX_CLIPBOARD_SNIPPET_LENGTH = parsePositiveInt(process.env.ZAP_MAX_CLIPBOARD_SNIPPET_LENGTH, 800); +const MAX_CLIPBOARD_ITEMS = parsePositiveInt(process.env.ZAP_MAX_CLIPBOARD_ITEMS, 200); +const MAX_TRACKED_CHAT_MESSAGES = parsePositiveInt(process.env.ZAP_MAX_TRACKED_CHAT_MESSAGES, 500); +const MAX_MIME_TYPE_LENGTH = 128; +const RELAY_TYPES = new Set([ + 'offer', + 'answer', + 'ice-candidate', + 'file-request', + 'file-accept', + 'file-decline', + 'transfer-cancel', +]); +const ALLOWED_DEVICE_TYPES = new Set(['phone', 'tablet', 'desktop', 'unknown']); const app = express(); +app.disable('x-powered-by'); + +app.use((req, res, next) => { + res.set('X-Content-Type-Options', 'nosniff'); + res.set('X-Frame-Options', 'DENY'); + res.set('Referrer-Policy', 'no-referrer'); + res.set('Cross-Origin-Resource-Policy', 'same-origin'); + res.set('Cross-Origin-Opener-Policy', 'same-origin'); + res.set('Permissions-Policy', 'camera=(self), microphone=(), geolocation=()'); + + if (req.socket.encrypted) { + res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + next(); +}); function getLanIPv4Addresses() { const interfaces = os.networkInterfaces(); @@ -26,23 +73,76 @@ function getLanIPv4Addresses() { return [...addresses]; } -app.get('/api/local-urls', (req, res) => { - const forwardedProto = req.headers['x-forwarded-proto']; - const baseProto = typeof forwardedProto === 'string' - ? forwardedProto.split(',')[0].trim() - : ''; - const protocol = baseProto || (req.socket.encrypted ? 'https' : 'http'); +function normalizeHost(value) { + if (typeof value !== 'string') return ''; + return value.trim().toLowerCase(); +} + +function isSameOriginWebSocket(req) { + const hostHeader = normalizeHost(req.headers.host); + const originHeader = req.headers.origin; + + if (!hostHeader) return false; + if (!originHeader) return isDev; + + try { + const origin = new URL(originHeader); + return normalizeHost(origin.host) === hostHeader; + } catch { + return false; + } +} + +function hasValidJoinToken(urlPath, hostHeader) { + if (!wsJoinToken) return true; + + try { + const parsed = new URL(urlPath || '/', `http://${hostHeader || 'localhost'}`); + const token = parsed.searchParams.get('token'); + return token === wsJoinToken; + } catch { + return false; + } +} + +function requireJoinToken(req, res, next) { + if (!wsJoinToken) { + next(); + return; + } + + const token = typeof req.query.token === 'string' ? req.query.token : ''; + if (token === wsJoinToken) { + next(); + return; + } + + res.status(401).json({ error: 'Missing or invalid token' }); +} + +function buildShareUrl(protocol, ip, portSegment) { + const base = `${protocol}://${ip}${portSegment}/`; + return wsJoinToken + ? `${base}?token=${encodeURIComponent(wsJoinToken)}` + : base; +} + +app.get('/api/local-urls', requireJoinToken, (req, res) => { + const protocol = req.socket.encrypted ? 'https' : 'http'; const omitPort = (protocol === 'http' && PORT === 80) || (protocol === 'https' && PORT === 443); const portSegment = omitPort ? '' : `:${PORT}`; - const urls = getLanIPv4Addresses().map((ip) => `${protocol}://${ip}${portSegment}`); + const urls = getLanIPv4Addresses().map((ip) => buildShareUrl(protocol, ip, portSegment)); + res.set('Cache-Control', 'no-store'); res.json({ urls }); }); app.get('/app-config.js', (req, res) => { res.type('application/javascript'); res.set('Cache-Control', 'no-store'); - res.send(`window.__ZAP_CONFIG__ = { isDev: ${JSON.stringify(isDev)} };`); + res.send( + `window.__ZAP_CONFIG__ = { isDev: ${JSON.stringify(isDev)}, tokenRequired: ${JSON.stringify(Boolean(wsJoinToken))} };` + ); }); app.use(express.static(path.join(__dirname, 'public'))); @@ -51,8 +151,16 @@ let server; const certPath = path.join(__dirname, 'certs', 'fullchain.pem'); const keyPath = path.join(__dirname, 'certs', 'privkey.pem'); +const hasTlsCerts = fs.existsSync(certPath) && fs.existsSync(keyPath); -if (!isDev && fs.existsSync(certPath) && fs.existsSync(keyPath)) { +if (!isDev && !hasTlsCerts && !allowInsecureHttp) { + console.error('TLS certificate files were not found in certs/.'); + console.error('Expected: certs/fullchain.pem and certs/privkey.pem'); + console.error('Refusing to start without HTTPS. For local-only testing, use npm run dev or pass --insecure-http.'); + process.exit(1); +} + +if (!isDev && hasTlsCerts) { const https = require('https'); server = https.createServer( { cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath) }, @@ -63,10 +171,12 @@ if (!isDev && fs.existsSync(certPath) && fs.existsSync(keyPath)) { const http = require('http'); server = http.createServer(app); if (!isDev) { - console.log('Warning: No TLS certs found in certs/. Running plain HTTP.'); + console.log('Warning: Running plain HTTP due to explicit insecure override.'); console.log('WebRTC will NOT work on iOS Safari without HTTPS.'); + console.log('Starting HTTP server (insecure override)'); + } else { + console.log('Starting HTTP server (dev mode)'); } - console.log('Starting HTTP server (dev mode)'); } let startupFailed = false; @@ -88,83 +198,393 @@ function failStartup(err) { // --- Signaling --- -const wss = new WebSocketServer({ server }); +const wss = new WebSocketServer({ + noServer: true, + maxPayload: MAX_WS_PAYLOAD, +}); wss.on('error', failStartup); const peers = new Map(); // peerId -> { ws, name, deviceType } +const clipboardSnippets = []; +const chatMessageOwners = new Map(); // messageId -> peerId +const chatMessageOrder = []; +let nextChatId = 1; +let nextClipboardId = 1; let nextId = 1; +function safeSend(ws, payload) { + if (ws.readyState !== WebSocket.OPEN) return false; + try { + ws.send(payload); + return true; + } catch { + return false; + } +} + function broadcastPeerList() { const list = []; for (const [id, peer] of peers) { list.push({ id, name: peer.name, deviceType: peer.deviceType }); } + const msg = JSON.stringify({ type: 'peers', peers: list }); - for (const [, peer] of peers) { - peer.ws.send(msg); + const stale = []; + for (const [id, peer] of peers) { + if (!safeSend(peer.ws, msg)) stale.push(id); + } + for (const id of stale) { + peers.delete(id); } } function relay(fromId, toId, message) { const target = peers.get(toId); if (target) { - target.ws.send(JSON.stringify({ ...message, from: fromId })); + safeSend(target.ws, JSON.stringify({ ...message, from: fromId })); + } +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function sanitizeDeviceType(value) { + return ALLOWED_DEVICE_TYPES.has(value) ? value : 'unknown'; +} + +function sanitizeDeviceName(value, fallback) { + if (typeof value !== 'string') return fallback; + const trimmed = value.trim().slice(0, MAX_DEVICE_NAME_LENGTH); + return trimmed || fallback; +} + +function sanitizeSdp(value, expectedType) { + if (!isPlainObject(value)) return null; + if (value.type !== expectedType) return null; + if (typeof value.sdp !== 'string') return null; + if (value.sdp.length === 0 || value.sdp.length > MAX_SDP_LENGTH) return null; + return { type: value.type, sdp: value.sdp }; +} + +function sanitizeIceCandidate(value) { + if (!isPlainObject(value)) return null; + if (typeof value.candidate !== 'string' || value.candidate.length > MAX_ICE_CANDIDATE_LENGTH) { + return null; + } + + const candidate = { + candidate: value.candidate, + sdpMid: typeof value.sdpMid === 'string' ? value.sdpMid : null, + sdpMLineIndex: Number.isInteger(value.sdpMLineIndex) ? value.sdpMLineIndex : null, + }; + + if (typeof value.usernameFragment === 'string') { + candidate.usernameFragment = value.usernameFragment; + } + + return candidate; +} + +function sanitizeFileMeta(value) { + if (!isPlainObject(value)) return null; + if (typeof value.name !== 'string') return null; + + const name = value.name.trim().slice(0, MAX_FILE_NAME_LENGTH); + if (!name) return null; + + const size = Number(value.size); + if (!Number.isFinite(size) || size < 0 || size > MAX_TRANSFER_BYTES || !Number.isInteger(size)) { + return null; + } + + let mimeType = ''; + if (typeof value.mimeType === 'string') { + mimeType = value.mimeType.slice(0, MAX_MIME_TYPE_LENGTH); + } + + return { + name, + size, + mimeType, + }; +} + +function sanitizeRelayMessage(type, msg) { + switch (type) { + case 'offer': + case 'answer': { + const sdp = sanitizeSdp(msg.sdp, type); + return sdp ? { type, sdp } : null; + } + case 'ice-candidate': { + const candidate = sanitizeIceCandidate(msg.candidate); + return candidate ? { type, candidate } : null; + } + case 'file-request': { + const meta = sanitizeFileMeta(msg.meta); + return meta ? { type, meta } : null; + } + case 'file-accept': + case 'file-decline': + case 'transfer-cancel': + return { type }; + default: + return null; } } +function sanitizeChatText(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_CHAT_MESSAGE_LENGTH) return null; + return trimmed; +} + +function sanitizeClipboardText(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_CLIPBOARD_SNIPPET_LENGTH) return null; + return trimmed; +} + +function sanitizeItemId(value) { + if (typeof value !== 'string') return null; + const id = value.trim(); + if (!id || id.length > 64) return null; + return id; +} + +server.on('upgrade', (req, socket, head) => { + const host = normalizeHost(req.headers.host); + + if (!isSameOriginWebSocket(req)) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; + } + + if (!hasValidJoinToken(req.url, host)) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit('connection', ws, req); + }); +}); + wss.on('connection', (ws) => { const peerId = String(nextId++); + let registered = false; + let cleanedUp = false; + let windowStartedAt = Date.now(); + let messagesInWindow = 0; + + function cleanup() { + if (cleanedUp) return; + cleanedUp = true; + if (peers.delete(peerId)) { + broadcastPeerList(); + } + } + + function closePolicy(reason) { + try { + ws.close(1008, reason); + } catch { + cleanup(); + } + } + + ws.on('message', (raw, isBinary) => { + if (isBinary) { + closePolicy('Binary signaling is not supported'); + return; + } + + const now = Date.now(); + if (now - windowStartedAt > WS_RATE_LIMIT_WINDOW_MS) { + windowStartedAt = now; + messagesInWindow = 0; + } + messagesInWindow += 1; + if (messagesInWindow > WS_RATE_LIMIT_MAX_MESSAGES) { + closePolicy('Too many signaling messages'); + return; + } - ws.on('message', (raw) => { let msg; try { - msg = JSON.parse(raw); + const text = typeof raw === 'string' ? raw : raw.toString('utf8'); + msg = JSON.parse(text); } catch { return; } + if (!isPlainObject(msg) || typeof msg.type !== 'string') return; + switch (msg.type) { case 'register': { peers.set(peerId, { ws, - name: msg.name || `Device ${peerId}`, - deviceType: msg.deviceType || 'unknown', + name: sanitizeDeviceName(msg.name, `Device ${peerId}`), + deviceType: sanitizeDeviceType(msg.deviceType), }); - ws.send(JSON.stringify({ type: 'registered', id: peerId })); + if (!registered) { + registered = true; + safeSend(ws, JSON.stringify({ type: 'registered', id: peerId })); + } + safeSend(ws, JSON.stringify({ type: 'clipboard-state', snippets: clipboardSnippets })); broadcastPeerList(); break; } - case 'offer': - case 'answer': - case 'ice-candidate': - case 'file-request': - case 'file-accept': - case 'file-decline': - case 'transfer-cancel': { - if (msg.to) relay(peerId, msg.to, msg); + case 'chat-message': { + if (!registered) break; + const text = sanitizeChatText(msg.text); + if (!text) break; + + const sender = peers.get(peerId); + if (!sender) break; + const id = String(nextChatId++); + chatMessageOwners.set(id, peerId); + chatMessageOrder.push(id); + if (chatMessageOrder.length > MAX_TRACKED_CHAT_MESSAGES) { + const removed = chatMessageOrder.shift(); + if (removed) chatMessageOwners.delete(removed); + } + + const payload = JSON.stringify({ + type: 'chat-message', + id, + from: peerId, + name: sender.name, + text, + ts: Date.now(), + }); + + const stale = []; + for (const [id, peer] of peers) { + if (!safeSend(peer.ws, payload)) stale.push(id); + } + for (const id of stale) { + peers.delete(id); + } break; } - default: + case 'chat-delete': { + if (!registered) break; + const id = sanitizeItemId(msg.id); + if (!id) break; + const owner = chatMessageOwners.get(id); + if (owner !== peerId) break; + + chatMessageOwners.delete(id); + const idx = chatMessageOrder.indexOf(id); + if (idx >= 0) chatMessageOrder.splice(idx, 1); + + const payload = JSON.stringify({ + type: 'chat-delete', + id, + }); + + const stale = []; + for (const [targetId, peer] of peers) { + if (!safeSend(peer.ws, payload)) stale.push(targetId); + } + for (const targetId of stale) { + peers.delete(targetId); + } break; - } - }); + } - ws.on('close', () => { - peers.delete(peerId); - broadcastPeerList(); - }); + case 'clipboard-add': { + if (!registered) break; + const text = sanitizeClipboardText(msg.text); + if (!text) break; + + const sender = peers.get(peerId); + if (!sender) break; + + const snippet = { + id: String(nextClipboardId++), + from: peerId, + name: sender.name, + text, + ts: Date.now(), + }; + + clipboardSnippets.push(snippet); + if (clipboardSnippets.length > MAX_CLIPBOARD_ITEMS) { + clipboardSnippets.splice(0, clipboardSnippets.length - MAX_CLIPBOARD_ITEMS); + } + + const payload = JSON.stringify({ + type: 'clipboard-add', + snippet, + }); - ws.on('error', () => { - peers.delete(peerId); - broadcastPeerList(); + const stale = []; + for (const [id, peer] of peers) { + if (!safeSend(peer.ws, payload)) stale.push(id); + } + for (const id of stale) { + peers.delete(id); + } + break; + } + + case 'clipboard-delete': { + if (!registered) break; + const id = sanitizeItemId(msg.id); + if (!id) break; + const index = clipboardSnippets.findIndex((snippet) => snippet.id === id); + if (index < 0) break; + clipboardSnippets.splice(index, 1); + + const payload = JSON.stringify({ + type: 'clipboard-delete', + id, + }); + + const stale = []; + for (const [targetId, peer] of peers) { + if (!safeSend(peer.ws, payload)) stale.push(targetId); + } + for (const targetId of stale) { + peers.delete(targetId); + } + break; + } + + default: { + if (!registered || !RELAY_TYPES.has(msg.type)) break; + if (typeof msg.to !== 'string' || msg.to.length === 0 || msg.to.length > 32) break; + if (msg.to === peerId) break; + if (!peers.has(msg.to)) break; + + const sanitized = sanitizeRelayMessage(msg.type, msg); + if (!sanitized) break; + + relay(peerId, msg.to, sanitized); + break; + } + } }); + + ws.on('close', cleanup); + ws.on('error', cleanup); }); server.on('error', failStartup); server.listen(PORT, HOST, () => { console.log(`Zap server listening on ${HOST}:${PORT}`); + if (wsJoinToken) { + console.log('Join token auth enabled for WebSocket and local URL API.'); + } if (isDev) { const localHost = HOST === '0.0.0.0' ? 'localhost' : HOST; console.log(` http://${localHost}:${PORT}`); diff --git a/tests/server.smoke.test.mjs b/tests/server.smoke.test.mjs index 88df8cc..4733a9a 100644 --- a/tests/server.smoke.test.mjs +++ b/tests/server.smoke.test.mjs @@ -3,6 +3,7 @@ import { spawn } from 'node:child_process'; import http from 'node:http'; import net from 'node:net'; import test from 'node:test'; +import { WebSocket } from 'ws'; function getFreePort() { return new Promise((resolve, reject) => { @@ -82,7 +83,7 @@ function httpGet(host, port, path) { }); } -test('server boots and serves core local endpoints', async (t) => { +async function startServer(t, envOverrides = {}) { const port = await getFreePort(); const expectedLine = `Zap server listening on 127.0.0.1:${port}`; @@ -92,6 +93,7 @@ test('server boots and serves core local endpoints', async (t) => { ...process.env, HOST: '127.0.0.1', PORT: String(port), + ...envOverrides, }, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -101,6 +103,163 @@ test('server boots and serves core local endpoints', async (t) => { }); await waitForServerReady(proc, expectedLine); + return { port, proc }; +} + +function expectWebSocketRejected(url, origin, timeoutMs = 5000) { + return new Promise((resolve) => { + const ws = new WebSocket(url, { + headers: { Origin: origin }, + }); + + let settled = false; + const timeout = setTimeout(() => { + finish({ opened: false, reason: 'timeout' }); + }, timeoutMs); + + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + try { ws.terminate(); } catch {} + resolve(result); + }; + + ws.on('open', () => finish({ opened: true })); + ws.on('unexpected-response', (_req, res) => { + finish({ opened: false, statusCode: res.statusCode || 0 }); + }); + ws.on('error', () => finish({ opened: false, reason: 'error' })); + }); +} + +function connectWebSocket(url, origin, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, { + headers: { Origin: origin }, + }); + + const timeout = setTimeout(() => { + cleanup(); + reject(new Error('Timed out waiting for WebSocket open')); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeout); + ws.off('open', onOpen); + ws.off('error', onError); + ws.off('unexpected-response', onUnexpectedResponse); + }; + + const onOpen = () => { + cleanup(); + resolve(ws); + }; + + const onError = (err) => { + cleanup(); + reject(err); + }; + + const onUnexpectedResponse = (_req, res) => { + cleanup(); + reject(new Error(`Unexpected WebSocket response ${res.statusCode || 0}`)); + }; + + ws.on('open', onOpen); + ws.on('error', onError); + ws.on('unexpected-response', onUnexpectedResponse); + }); +} + +function waitForMessageType(ws, type, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for message type "${type}"`)); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeout); + ws.off('message', onMessage); + ws.off('close', onClose); + ws.off('error', onError); + }; + + const onMessage = (raw) => { + let msg; + try { + msg = JSON.parse(raw.toString()); + } catch { + return; + } + if (msg.type === type) { + cleanup(); + resolve(msg); + } + }; + + const onClose = () => { + cleanup(); + reject(new Error(`WebSocket closed before receiving "${type}"`)); + }; + + const onError = (err) => { + cleanup(); + reject(err); + }; + + ws.on('message', onMessage); + ws.on('close', onClose); + ws.on('error', onError); + }); +} + +function expectNoMessageType(ws, type, waitMs = 400) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + resolve(); + }, waitMs); + + const cleanup = () => { + clearTimeout(timeout); + ws.off('message', onMessage); + ws.off('close', onClose); + ws.off('error', onError); + }; + + const onMessage = (raw) => { + let msg; + try { + msg = JSON.parse(raw.toString()); + } catch { + return; + } + if (msg.type === type) { + cleanup(); + reject(new Error(`Unexpected message type "${type}"`)); + } + }; + + const onClose = () => { + cleanup(); + reject(new Error(`WebSocket closed while waiting for no "${type}" message`)); + }; + + const onError = (err) => { + cleanup(); + reject(err); + }; + + ws.on('message', onMessage); + ws.on('close', onClose); + ws.on('error', onError); + }); +} + +test('server boots and serves core local endpoints', async (t) => { + const { port } = await startServer(t); const health = await httpGet('127.0.0.1', port, '/api/local-urls'); assert.equal(health.statusCode, 200); @@ -111,3 +270,228 @@ test('server boots and serves core local endpoints', async (t) => { assert.equal(homepage.statusCode, 200); assert.match(homepage.body, /Zap<\/title>/i); }); + +test('websocket rejects mismatched Origin', async (t) => { + const { port } = await startServer(t); + const result = await expectWebSocketRejected( + `ws://127.0.0.1:${port}/`, + 'https://evil.example' + ); + + assert.equal(result.opened, false); +}); + +test('websocket allows same-origin registration', async (t) => { + const { port } = await startServer(t); + const origin = `http://127.0.0.1:${port}`; + const ws = await connectWebSocket(`ws://127.0.0.1:${port}/`, origin); + + t.after(() => { + if (ws.readyState === WebSocket.OPEN) ws.close(); + }); + + ws.send(JSON.stringify({ + type: 'register', + name: 'test-client', + deviceType: 'desktop', + })); + + const registered = await waitForMessageType(ws, 'registered'); + assert.equal(typeof registered.id, 'string'); +}); + +test('websocket enforces join token when configured', async (t) => { + const token = 'test-join-token'; + const { port } = await startServer(t, { ZAP_JOIN_TOKEN: token }); + const origin = `http://127.0.0.1:${port}`; + + const rejected = await expectWebSocketRejected(`ws://127.0.0.1:${port}/`, origin); + assert.equal(rejected.opened, false); + + const ws = await connectWebSocket(`ws://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`, origin); + t.after(() => { + if (ws.readyState === WebSocket.OPEN) ws.close(); + }); + + ws.send(JSON.stringify({ type: 'register', name: 'token-client', deviceType: 'desktop' })); + const registered = await waitForMessageType(ws, 'registered'); + assert.equal(typeof registered.id, 'string'); +}); + +test('local URL API enforces join token when configured', async (t) => { + const token = 'api-token'; + const { port } = await startServer(t, { ZAP_JOIN_TOKEN: token }); + + const denied = await httpGet('127.0.0.1', port, '/api/local-urls'); + assert.equal(denied.statusCode, 401); + + const allowed = await httpGet('127.0.0.1', port, `/api/local-urls?token=${encodeURIComponent(token)}`); + assert.equal(allowed.statusCode, 200); + const parsed = JSON.parse(allowed.body); + assert.ok(Array.isArray(parsed.urls)); + assert.ok(parsed.urls.every((url) => url.includes(`token=${encodeURIComponent(token)}`))); +}); + +test('chat messages broadcast to all registered peers', async (t) => { + const { port } = await startServer(t); + const origin = `http://127.0.0.1:${port}`; + + const wsA = await connectWebSocket(`ws://127.0.0.1:${port}/`, origin); + const wsB = await connectWebSocket(`ws://127.0.0.1:${port}/`, origin); + + t.after(() => { + if (wsA.readyState === WebSocket.OPEN) wsA.close(); + if (wsB.readyState === WebSocket.OPEN) wsB.close(); + }); + + wsA.send(JSON.stringify({ type: 'register', name: 'alpha', deviceType: 'desktop' })); + wsB.send(JSON.stringify({ type: 'register', name: 'bravo', deviceType: 'desktop' })); + await waitForMessageType(wsA, 'registered'); + await waitForMessageType(wsB, 'registered'); + + wsA.send(JSON.stringify({ type: 'chat-message', text: 'hello from alpha' })); + + const receivedByA = await waitForMessageType(wsA, 'chat-message'); + const receivedByB = await waitForMessageType(wsB, 'chat-message'); + + assert.equal(receivedByA.text, 'hello from alpha'); + assert.equal(receivedByB.text, 'hello from alpha'); + assert.equal(receivedByA.name, 'alpha'); + assert.equal(receivedByB.name, 'alpha'); + assert.equal(typeof receivedByA.id, 'string'); + assert.equal(typeof receivedByB.id, 'string'); + assert.equal(typeof receivedByA.ts, 'number'); + assert.equal(typeof receivedByB.ts, 'number'); +}); + +test('chat messages can only be deleted by the sender', async (t) => { + const { port } = await startServer(t); + const origin = `http://127.0.0.1:${port}`; + + const wsA = await connectWebSocket(`ws://127.0.0.1:${port}/`, origin); + const wsB = await connectWebSocket(`ws://127.0.0.1:${port}/`, origin); + + t.after(() => { + if (wsA.readyState === WebSocket.OPEN) wsA.close(); + if (wsB.readyState === WebSocket.OPEN) wsB.close(); + }); + + wsA.send(JSON.stringify({ type: 'register', name: 'alpha', deviceType: 'desktop' })); + wsB.send(JSON.stringify({ type: 'register', name: 'bravo', deviceType: 'desktop' })); + await waitForMessageType(wsA, 'registered'); + await waitForMessageType(wsB, 'registered'); + + wsA.send(JSON.stringify({ type: 'chat-message', text: 'sender only delete' })); + const chatA = await waitForMessageType(wsA, 'chat-message'); + const chatB = await waitForMessageType(wsB, 'chat-message'); + assert.equal(chatA.id, chatB.id); + const messageId = chatA.id; + + const noDeleteA = expectNoMessageType(wsA, 'chat-delete'); + const noDeleteB = expectNoMessageType(wsB, 'chat-delete'); + wsB.send(JSON.stringify({ type: 'chat-delete', id: messageId })); + await noDeleteA; + await noDeleteB; + + wsA.send(JSON.stringify({ type: 'chat-delete', id: messageId })); + const deleteA = await waitForMessageType(wsA, 'chat-delete'); + const deleteB = await waitForMessageType(wsB, 'chat-delete'); + assert.equal(deleteA.id, messageId); + assert.equal(deleteB.id, messageId); +}); + +test('clipboard snippets broadcast and sync to new peers', async (t) => { + const { port } = await startServer(t); + const origin = `http://127.0.0.1:${port}`; + + const wsA = await connectWebSocket(`ws://127.0.0.1:${port}/`, origin); + const wsB = await connectWebSocket(`ws://127.0.0.1:${port}/`, origin); + + t.after(() => { + if (wsA.readyState === WebSocket.OPEN) wsA.close(); + if (wsB.readyState === WebSocket.OPEN) wsB.close(); + }); + + const wsARegistered = waitForMessageType(wsA, 'registered'); + const wsAClipboardState = waitForMessageType(wsA, 'clipboard-state'); + const wsBRegistered = waitForMessageType(wsB, 'registered'); + const wsBClipboardState = waitForMessageType(wsB, 'clipboard-state'); + wsA.send(JSON.stringify({ type: 'register', name: 'alpha', deviceType: 'desktop' })); + wsB.send(JSON.stringify({ type: 'register', name: 'bravo', deviceType: 'desktop' })); + await wsARegistered; + await wsAClipboardState; + await wsBRegistered; + await wsBClipboardState; + + wsA.send(JSON.stringify({ type: 'clipboard-add', text: 'const LAN = true;' })); + + const addA = await waitForMessageType(wsA, 'clipboard-add'); + const addB = await waitForMessageType(wsB, 'clipboard-add'); + + assert.equal(addA.snippet.text, 'const LAN = true;'); + assert.equal(addB.snippet.text, 'const LAN = true;'); + assert.equal(addA.snippet.name, 'alpha'); + assert.equal(addB.snippet.name, 'alpha'); + assert.equal(typeof addA.snippet.id, 'string'); + assert.equal(typeof addB.snippet.id, 'string'); + + const wsC = await connectWebSocket(`ws://127.0.0.1:${port}/`, origin); + t.after(() => { + if (wsC.readyState === WebSocket.OPEN) wsC.close(); + }); + const wsCRegistered = waitForMessageType(wsC, 'registered'); + const wsCClipboardState = waitForMessageType(wsC, 'clipboard-state'); + wsC.send(JSON.stringify({ type: 'register', name: 'charlie', deviceType: 'desktop' })); + await wsCRegistered; + const stateC = await wsCClipboardState; + assert.ok(Array.isArray(stateC.snippets)); + assert.ok(stateC.snippets.some((snippet) => snippet.text === 'const LAN = true;')); +}); + +test('clipboard snippets can be deleted and are removed from sync state', async (t) => { + const { port } = await startServer(t); + const origin = `http://127.0.0.1:${port}`; + + const wsA = await connectWebSocket(`ws://127.0.0.1:${port}/`, origin); + const wsB = await connectWebSocket(`ws://127.0.0.1:${port}/`, origin); + + t.after(() => { + if (wsA.readyState === WebSocket.OPEN) wsA.close(); + if (wsB.readyState === WebSocket.OPEN) wsB.close(); + }); + + const wsARegistered = waitForMessageType(wsA, 'registered'); + const wsAClipboardState = waitForMessageType(wsA, 'clipboard-state'); + const wsBRegistered = waitForMessageType(wsB, 'registered'); + const wsBClipboardState = waitForMessageType(wsB, 'clipboard-state'); + wsA.send(JSON.stringify({ type: 'register', name: 'alpha', deviceType: 'desktop' })); + wsB.send(JSON.stringify({ type: 'register', name: 'bravo', deviceType: 'desktop' })); + await wsARegistered; + await wsAClipboardState; + await wsBRegistered; + await wsBClipboardState; + + wsA.send(JSON.stringify({ type: 'clipboard-add', text: 'delete me' })); + const addA = await waitForMessageType(wsA, 'clipboard-add'); + const addB = await waitForMessageType(wsB, 'clipboard-add'); + assert.equal(addA.snippet.id, addB.snippet.id); + const snippetId = addA.snippet.id; + + wsB.send(JSON.stringify({ type: 'clipboard-delete', id: snippetId })); + const deleteA = await waitForMessageType(wsA, 'clipboard-delete'); + const deleteB = await waitForMessageType(wsB, 'clipboard-delete'); + assert.equal(deleteA.id, snippetId); + assert.equal(deleteB.id, snippetId); + + const wsC = await connectWebSocket(`ws://127.0.0.1:${port}/`, origin); + t.after(() => { + if (wsC.readyState === WebSocket.OPEN) wsC.close(); + }); + const wsCRegistered = waitForMessageType(wsC, 'registered'); + const wsCClipboardState = waitForMessageType(wsC, 'clipboard-state'); + wsC.send(JSON.stringify({ type: 'register', name: 'charlie', deviceType: 'desktop' })); + await wsCRegistered; + const stateC = await wsCClipboardState; + assert.ok(Array.isArray(stateC.snippets)); + assert.ok(stateC.snippets.every((snippet) => snippet.id !== snippetId)); +});