diff --git a/README.md b/README.md index 307e57d..82b45f6 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,22 @@ Add this to your `services.yaml`: | --- | --- | --- | | `PORT` | `3000` | The port the web dashboard listens on. Since `--network host` is used, this port opens directly on the host. | | `MAX_PEERS` | `1000000` | Maximum number of peers to track in the swarm. Unless you're expecting the entire internet to join, the default is probably fine. | +| `ENABLE_CHAT` | `false` | Set to `true` to enable the ephemeral P2P chat terminal. | + +## » Features + +### 1. The Counter +It counts. That's the main thing. + +### 2. Ephemeral Chat +**New:** A completely decentralized, ephemeral chat system built directly on top of the swarm topology. + +* **Ephemeral:** No database. No history. If you refresh, it's gone. +* **Restricted:** You can only talk to your ~32 direct connections. +* **Chaotic:** Every 30 seconds, the network rotates your connections. You might be mid-sentence and—*poof*—your audience changes. +* **Anonymous:** You are identified only by the last 4 characters of your node ID. + +To enable this feature, set `ENABLE_CHAT=true`. ## » Usage diff --git a/public/app.js b/public/app.js index 239964d..3878f37 100644 --- a/public/app.js +++ b/public/app.js @@ -96,6 +96,275 @@ document.getElementById('diagnosticsModal').addEventListener('click', (e) => { document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeDiagnostics(); + closeMap(); + } +}); + +// Map Logic +let map = null; +let mapInitialized = false; +let peerMarkers = {}; // id -> marker +let ipCache = {}; // ip -> { lat, lon } +let lastPeerData = []; +let myLocation = null; + +const fetchMyLocation = async () => { + if (myLocation) return; + try { + const res = await fetch('https://ipwho.is/'); + const data = await res.json(); + if (data.success) { + myLocation = { lat: data.latitude, lon: data.longitude, city: data.city, country: data.country }; + updateMap(lastPeerData); + } + } catch (e) { + console.error('My location fetch failed', e); + } +} + +const openMap = () => { + document.getElementById('mapModal').classList.add('active'); + if (!mapInitialized) { + initMap(); + } else { + setTimeout(() => { + map.invalidateSize(); + }, 100); + } + + fetchMyLocation(); + + if (lastPeerData.length > 0) { + updateMap(lastPeerData); + } +} + +const closeMap = () => { + document.getElementById('mapModal').classList.remove('active'); +} + +document.getElementById('mapModal').addEventListener('click', (e) => { + if (e.target.id === 'mapModal') { + closeMap(); + } +}); + +const initMap = () => { + if (mapInitialized) return; + + map = L.map('map').setView([20, 0], 2); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 19 + }).addTo(map); + + mapInitialized = true; + + setTimeout(() => { + map.invalidateSize(); + }, 100); +} + +const fetchLocation = async (ip) => { + if (ipCache[ip]) return ipCache[ip]; + + // Skip local IPs + if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip.startsWith('172.16.')) { + return null; + } + + try { + const res = await fetch(`https://ipwho.is/${ip}`); + const data = await res.json(); + if (data.success) { + const loc = { lat: data.latitude, lon: data.longitude, city: data.city, country: data.country }; + ipCache[ip] = loc; + return loc; + } + } catch (e) { + console.error('Geo fetch failed', e); + } + return null; +} + +const updateMap = async (peers) => { + if (!mapInitialized) return; + if (!peers) peers = []; + + const currentIds = new Set(peers.map(p => p.id)); + + // Remove old markers + for (const id in peerMarkers) { + if (id !== 'me' && !currentIds.has(id)) { + map.removeLayer(peerMarkers[id]); + delete peerMarkers[id]; + } + } + + // Add/Update markers + for (const peer of peers) { + if (!peer.ip) continue; + + if (!peerMarkers[peer.id]) { + const loc = await fetchLocation(peer.ip); + if (loc) { + const marker = L.circleMarker([loc.lat, loc.lon], { + radius: 10, + fillColor: "#4ade80", + color: "transparent", + weight: 0, + opacity: 0, + fillOpacity: 0.15 + }).addTo(map); + + marker.bindPopup(`Node ${peer.id.slice(-8)}
${loc.city}, ${loc.country}`); + peerMarkers[peer.id] = marker; + } + } + } + + // Add My Location + if (myLocation && !peerMarkers['me']) { + const marker = L.circleMarker([myLocation.lat, myLocation.lon], { + radius: 6, + fillColor: "#ffffff", + color: "#4ade80", + weight: 2, + opacity: 1, + fillOpacity: 1 + }).addTo(map); + + marker.bindPopup(`This Node
${myLocation.city}, ${myLocation.country}`); + peerMarkers['me'] = marker; + } +} + +const terminal = document.getElementById('terminal'); +const terminalOutput = document.getElementById('terminal-output'); +const terminalInput = document.getElementById('terminal-input'); +const terminalToggle = document.getElementById('terminal-toggle'); +const promptEl = document.querySelector('.prompt'); +let myId = null; +let myChatHistory = []; + +terminalToggle.addEventListener('click', (e) => { + e.stopPropagation(); + toggleChat(); +}); + +// Initialize chat state from localStorage +const initChatState = () => { + const isCollapsed = localStorage.getItem('chatCollapsed') === 'true'; + if (isCollapsed) { + terminal.classList.add('collapsed'); + terminalToggle.innerText = '▲'; + document.body.classList.remove('chat-active'); + document.body.classList.add('chat-collapsed'); + } else { + terminal.classList.remove('collapsed'); + terminalToggle.innerText = '▼'; + document.body.classList.add('chat-active'); + document.body.classList.remove('chat-collapsed'); + } +}; + +const toggleChat = () => { + terminal.classList.toggle('collapsed'); + const isCollapsed = terminal.classList.contains('collapsed'); + terminalToggle.innerText = isCollapsed ? '▲' : '▼'; + + localStorage.setItem('chatCollapsed', isCollapsed); + + if (isCollapsed) { + document.body.classList.remove('chat-active'); + document.body.classList.add('chat-collapsed'); + } else { + document.body.classList.add('chat-active'); + document.body.classList.remove('chat-collapsed'); + terminalOutput.scrollTop = terminalOutput.scrollHeight; + } +} + +const updatePromptStatus = () => { + const now = Date.now(); + myChatHistory = myChatHistory.filter(t => now - t < 10000); + + if (myChatHistory.length >= 5) { + promptEl.style.color = 'orange'; + } else { + promptEl.style.color = '#4ade80'; + } +}; + +setInterval(updatePromptStatus, 500); + +const getColorFromId = (id) => { + if (!id) return '#666'; + let hash = 0; + for (let i = 0; i < id.length; i++) { + hash = id.charCodeAt(i) + ((hash << 5) - hash); + } + const c = (hash & 0x00FFFFFF).toString(16).toUpperCase(); + return '#' + "00000".substring(0, 6 - c.length) + c; +} + +const appendMessage = (msg) => { + const div = document.createElement('div'); + + if (msg.type === 'SYSTEM') { + div.className = 'msg-system'; + div.innerText = `[SYSTEM] ${msg.content}`; + } else if (msg.type === 'CHAT') { + const senderColor = getColorFromId(msg.sender); + const senderName = msg.sender === myId ? 'You' : msg.sender.slice(-4); + + const senderSpan = document.createElement('span'); + senderSpan.className = 'msg-sender'; + senderSpan.style.color = senderColor; + senderSpan.innerText = `[${senderName}]`; + + const contentSpan = document.createElement('span'); + contentSpan.className = 'msg-content'; + contentSpan.innerText = ` > ${msg.content}`; + + div.appendChild(senderSpan); + div.appendChild(contentSpan); + } + + terminalOutput.appendChild(div); + terminalOutput.scrollTop = terminalOutput.scrollHeight; +} + +terminalInput.addEventListener('keypress', async (e) => { + if (e.key === 'Enter') { + const content = terminalInput.value.trim(); + if (!content) return; + + terminalInput.value = ''; + + try { + const res = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }) + }); + + if (res.ok) { + myChatHistory.push(Date.now()); + updatePromptStatus(); + } else if (res.status === 429) { + // Force update if we hit the limit unexpectedly + // Add a dummy timestamp to force the limit state if not already there + if (myChatHistory.length < 5) { + myChatHistory.push(Date.now()); + } + updatePromptStatus(); + } + } catch (err) { + console.error('Failed to send message', err); + } } }); @@ -104,6 +373,34 @@ const evtSource = new EventSource("/events"); evtSource.onmessage = (event) => { const data = JSON.parse(event.data); + if (data.type === 'CHAT' || data.type === 'SYSTEM') { + appendMessage(data); + return; + } + + if (data.chatEnabled) { + terminal.classList.remove('hidden'); + + // Only initialize state once when chat becomes enabled + if (!terminal.dataset.initialized) { + initChatState(); + terminal.dataset.initialized = 'true'; + } + } else { + terminal.classList.add('hidden'); + document.body.classList.remove('chat-active'); + document.body.classList.remove('chat-collapsed'); + } + + if (data.id) myId = data.id; + + if (data.peers) { + lastPeerData = data.peers; + if (mapInitialized && document.getElementById('mapModal').classList.contains('active')) { + updateMap(data.peers); + } + } + updateParticles(data.count); if (countEl.innerText != data.count) { diff --git a/public/index.html b/public/index.html index 77d90c0..2aaae41 100644 --- a/public/index.html +++ b/public/index.html @@ -5,6 +5,8 @@ + + @@ -17,7 +19,15 @@
ID: {{ID}}
Direct Connections: {{DIRECT}}
- diagnostics + diagnostics | + map +
+ + + @@ -65,6 +75,14 @@ + diff --git a/public/style.css b/public/style.css index cd016e1..dae9a2c 100644 --- a/public/style.css +++ b/public/style.css @@ -9,6 +9,15 @@ body { background: #111; color: #eee; margin: 0; + transition: padding-bottom 0.3s ease; +} + +body.chat-active { + padding-bottom: 250px; +} + +body.chat-collapsed { + padding-bottom: 40px; } .container { text-align: center; position: relative; z-index: 10; } @@ -16,8 +25,26 @@ body { .count { font-size: 8rem; font-weight: bold; color: #4ade80; transition: color 0.2s; visibility: hidden; } .count.loaded { visibility: visible; } .label { font-size: 1.5rem; color: #9ca3af; margin-top: 1rem; } -.footer { margin-top: 2rem; font-size: 0.9rem; color: #4b5563; } -.debug { font-size: 0.8rem; color: #4b5563; margin-top: 1rem; } +.footer { + margin: 2rem auto 0; + font-size: 0.9rem; + color: #4b5563; + background: rgba(0, 0, 0, 0.6); + padding: 2px 8px; + border-radius: 4px; + display: block; + width: fit-content; +} +.debug { + font-size: 0.8rem; + color: #4b5563; + margin: 1rem auto 0; + background: rgba(0, 0, 0, 0.6); + padding: 5px 10px; + border-radius: 4px; + display: block; + width: fit-content; +} .debug-link { color: #4b5563; border-bottom: 1px dotted #4b5563; cursor: pointer; } .debug-link:hover { color: #9ca3af; } a { color: #4b5563; text-decoration: none; border-bottom: 1px dotted #4b5563; } @@ -48,6 +75,23 @@ a { color: #4b5563; text-decoration: none; border-bottom: 1px dotted #4b5563; } width: 90%; position: relative; } +.modal-content.map-content { + max-width: 1000px; + width: 100%; + height: 500px; + max-height: 80vh; + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +#map { + width: 100%; + height: 100%; + background: #222; +} + .modal-title { font-size: 0.9rem; color: #666; @@ -55,17 +99,19 @@ a { color: #4b5563; text-decoration: none; border-bottom: 1px dotted #4b5563; } text-transform: uppercase; letter-spacing: 1px; } + .close-btn { position: absolute; top: 1.5rem; right: 1.5rem; background: none; border: none; - color: #333; + color: #9ca3af; font-size: 1.2rem; cursor: pointer; + z-index: 1001; } -.close-btn:hover { color: #666; } +.close-btn:hover { color: #fff; } .stat-row { display: flex; justify-content: space-between; @@ -85,3 +131,125 @@ a { color: #4b5563; text-decoration: none; border-bottom: 1px dotted #4b5563; } color: #333; margin-top: 1rem; } + +.terminal { + position: fixed; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 800px; + max-width: 100%; + height: 250px; + background: rgba(0, 0, 0, 0.9); + border: 1px solid #333; + border-bottom: none; + border-radius: 8px 8px 0 0; + z-index: 100; + font-family: "Courier New", Courier, monospace; + display: flex; + flex-direction: column; + padding: 12px; + color: #4ade80; + font-size: 12px; + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3); + transition: transform 0.3s ease; +} + +.terminal.hidden { + display: none; +} + +.terminal.collapsed { + transform: translateX(-50%) translateY(100%); +} + +.terminal-toggle { + position: absolute; + top: -24px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.9); + border: 1px solid #333; + border-bottom: none; + border-radius: 8px 8px 0 0; + color: #4ade80; + cursor: pointer; + font-family: monospace; + font-weight: bold; + font-size: 14px; + z-index: 101; + padding: 2px 15px; + height: 24px; + line-height: 20px; + display: flex; + align-items: center; + justify-content: center; +} + +.terminal-toggle:hover { + color: #fff; + background: #222; +} + +.terminal-output { + flex: 1; + overflow-y: auto; + margin-bottom: 5px; + display: flex; + flex-direction: column; + gap: 2px; + scrollbar-width: thin; + scrollbar-color: #333 transparent; +} + +.terminal-output::-webkit-scrollbar { + width: 6px; +} + +.terminal-output::-webkit-scrollbar-track { + background: transparent; +} + +.terminal-output::-webkit-scrollbar-thumb { + background-color: #333; + border-radius: 3px; +} + +.terminal-output::-webkit-scrollbar-thumb:hover { + background-color: #444; +} + +.terminal-input-line { + display: flex; + align-items: center; + border-top: 1px solid #333; + padding-top: 10px; +} + +.prompt { + margin-right: 10px; + color: #4ade80; +} + +#terminal-input { + flex: 1; + background: transparent; + border: none; + color: #fff; + font-family: inherit; + font-size: inherit; + outline: none; +} + +.msg-system { + color: #666; + font-style: italic; +} + +.msg-sender { + font-weight: bold; +} + +.msg-content { + color: #ddd; +} diff --git a/server.js b/server.js index 3a690a0..5e0534a 100644 --- a/server.js +++ b/server.js @@ -8,7 +8,7 @@ const { relayMessage } = require("./src/p2p/relay"); const { SwarmManager } = require("./src/p2p/swarm"); const { SSEManager } = require("./src/web/sse"); const { createServer, startServer } = require("./src/web/server"); -const { DIAGNOSTICS_INTERVAL } = require("./src/config/constants"); +const { DIAGNOSTICS_INTERVAL, ENABLE_CHAT } = require("./src/config/constants"); const main = async () => { const identity = generateIdentity(); @@ -25,14 +25,26 @@ const main = async () => { direct: swarmManager.getSwarm().connections.size, id: identity.id, diagnostics: diagnostics.getStats(), + chatEnabled: ENABLE_CHAT, + peers: peerManager.getPeersWithIps() }); }; + const chatCallback = (msg) => { + sseManager.broadcast(msg); + }; + + const chatSystemFn = (msg) => { + sseManager.broadcast(msg); + }; + const messageHandler = new MessageHandler( peerManager, diagnostics, (msg, sourceSocket) => relayMessage(msg, sourceSocket, swarmManager.getSwarm(), diagnostics), - broadcastUpdate + broadcastUpdate, + chatCallback, + chatSystemFn ); const swarmManager = new SwarmManager( @@ -41,7 +53,8 @@ const main = async () => { diagnostics, messageHandler, (msg, sourceSocket) => relayMessage(msg, sourceSocket, swarmManager.getSwarm(), diagnostics), - broadcastUpdate + broadcastUpdate, + chatSystemFn ); await swarmManager.start(); diff --git a/src/config/constants.js b/src/config/constants.js index aba2c63..adf0d5f 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -24,6 +24,8 @@ const PEER_TIMEOUT = 15000; const BROADCAST_THROTTLE = 1000; const DIAGNOSTICS_INTERVAL = 10000; const PORT = process.env.PORT || 3000; +const ENABLE_CHAT = process.env.ENABLE_CHAT === 'true'; +const CHAT_RATE_LIMIT = 5000; module.exports = { TOPIC_NAME, @@ -39,4 +41,6 @@ module.exports = { BROADCAST_THROTTLE, DIAGNOSTICS_INTERVAL, PORT, + ENABLE_CHAT, + CHAT_RATE_LIMIT, }; diff --git a/src/p2p/messaging.js b/src/p2p/messaging.js index 67967c6..6154e02 100644 --- a/src/p2p/messaging.js +++ b/src/p2p/messaging.js @@ -1,15 +1,18 @@ const { verifyPoW, verifySignature, createPublicKey } = require("../core/security"); -const { MAX_RELAY_HOPS } = require("../config/constants"); +const { MAX_RELAY_HOPS, ENABLE_CHAT } = require("../config/constants"); const { BloomFilterManager } = require("../state/bloom"); class MessageHandler { - constructor(peerManager, diagnostics, relayCallback, broadcastCallback) { + constructor(peerManager, diagnostics, relayCallback, broadcastCallback, chatCallback, chatSystemFn) { this.peerManager = peerManager; this.diagnostics = diagnostics; this.relayCallback = relayCallback; this.broadcastCallback = broadcastCallback; + this.chatCallback = chatCallback; + this.chatSystemFn = chatSystemFn; this.bloomFilter = new BloomFilterManager(); this.bloomFilter.start(); + this.chatRateLimits = new Map(); } handleMessage(msg, sourceSocket) { @@ -21,6 +24,8 @@ class MessageHandler { this.handleHeartbeat(msg, sourceSocket); } else if (msg.type === "LEAVE") { this.handleLeave(msg, sourceSocket); + } else if (msg.type === "CHAT") { + this.handleChat(msg, sourceSocket); } } @@ -57,11 +62,26 @@ class MessageHandler { sourceSocket.peerId = id; } - const wasNew = this.peerManager.addOrUpdatePeer(id, seq); + const getIp = (sock) => { + if (sock.remoteAddress) return sock.remoteAddress; + if (sock.rawStream && sock.rawStream.remoteHost) return sock.rawStream.remoteHost; + if (sock.rawStream && sock.rawStream.remoteAddress) return sock.rawStream.remoteAddress; + return null; + }; + + const ip = (hops === 0) ? getIp(sourceSocket) : null; + const wasNew = this.peerManager.addOrUpdatePeer(id, seq, key, ip); if (wasNew) { this.diagnostics.increment("newPeersAdded"); this.broadcastCallback(); + if (ENABLE_CHAT && this.chatSystemFn && hops === 0) { + this.chatSystemFn({ + type: "SYSTEM", + content: `Connection established with Node ...${id.slice(-8)}`, + timestamp: Date.now() + }); + } } // Only relay if we haven't already relayed this message (bloom filter check) @@ -96,6 +116,14 @@ class MessageHandler { this.peerManager.removePeer(id); this.broadcastCallback(); + if (ENABLE_CHAT && this.chatSystemFn && hops === 0) { + this.chatSystemFn({ + type: "SYSTEM", + content: `Node ...${id.slice(-8)} disconnected.`, + timestamp: Date.now() + }); + } + // Use id:leave as key for LEAVE messages if (hops < MAX_RELAY_HOPS && !this.bloomFilter.hasRelayed(id, "leave")) { this.bloomFilter.markRelayed(id, "leave"); @@ -103,6 +131,33 @@ class MessageHandler { } } } + + handleChat(msg, sourceSocket) { + // Identity Verification: Ensure the sender matches the authenticated socket + if (!sourceSocket.peerId || sourceSocket.peerId !== msg.sender) { + return; + } + + // Rate Limiting: Prevent flooding (5 messages per 10 seconds per peer) + const now = Date.now(); + let rateData = this.chatRateLimits.get(msg.sender); + + if (!rateData || now - rateData.windowStart > 10000) { + // Reset window + rateData = { count: 0, windowStart: now }; + } + + if (rateData.count >= 5) { + return; // Drop message + } + + rateData.count++; + this.chatRateLimits.set(msg.sender, rateData); + + if (this.chatCallback) { + this.chatCallback(msg); + } + } } const validateMessage = (msg) => { @@ -127,6 +182,15 @@ const validateMessage = (msg) => { msg.id && typeof msg.hops === 'number' && msg.sig; } + if (msg.type === "CHAT") { + const allowedFields = ['type', 'sender', 'content', 'timestamp']; + const fields = Object.keys(msg); + return fields.every(f => allowedFields.includes(f)) && + msg.sender && + msg.content && typeof msg.content === 'string' && msg.content.length <= 140 && + typeof msg.timestamp === 'number'; + } + return false; } diff --git a/src/p2p/swarm.js b/src/p2p/swarm.js index a9b3fc9..2757eff 100644 --- a/src/p2p/swarm.js +++ b/src/p2p/swarm.js @@ -1,15 +1,16 @@ const Hyperswarm = require("hyperswarm"); const { signMessage } = require("../core/security"); -const { TOPIC, TOPIC_NAME, HEARTBEAT_INTERVAL, MAX_CONNECTIONS, CONNECTION_ROTATION_INTERVAL } = require("../config/constants"); +const { TOPIC, TOPIC_NAME, HEARTBEAT_INTERVAL, MAX_CONNECTIONS, CONNECTION_ROTATION_INTERVAL, ENABLE_CHAT } = require("../config/constants"); class SwarmManager { - constructor(identity, peerManager, diagnostics, messageHandler, relayFn, broadcastFn) { + constructor(identity, peerManager, diagnostics, messageHandler, relayFn, broadcastFn, chatSystemFn) { this.identity = identity; this.peerManager = peerManager; this.diagnostics = diagnostics; this.messageHandler = messageHandler; this.relayFn = relayFn; this.broadcastFn = broadcastFn; + this.chatSystemFn = chatSystemFn; this.swarm = new Hyperswarm(); this.heartbeatInterval = null; @@ -109,6 +110,13 @@ class SwarmManager { } if (oldest) { + if (ENABLE_CHAT && this.chatSystemFn && oldest.peerId) { + this.chatSystemFn({ + type: "SYSTEM", + content: `Connection with Node ...${oldest.peerId.slice(-8)} severed (Rotation).`, + timestamp: Date.now() + }); + } oldest.destroy(); } }, CONNECTION_ROTATION_INTERVAL); @@ -143,6 +151,14 @@ class SwarmManager { getSwarm() { return this.swarm; } + + broadcastChat(msg) { + if (!ENABLE_CHAT) return; + const msgStr = JSON.stringify(msg) + "\n"; + for (const socket of this.swarm.connections) { + socket.write(msgStr); + } + } } module.exports = { SwarmManager }; diff --git a/src/state/peers.js b/src/state/peers.js index 829128e..5f6325b 100644 --- a/src/state/peers.js +++ b/src/state/peers.js @@ -9,7 +9,7 @@ class PeerManager { this.mySeq = 0; } - addOrUpdatePeer(id, seq) { + addOrUpdatePeer(id, seq, key, ip = null) { const stored = this.seenPeers.get(id); const wasNew = !stored; @@ -19,6 +19,8 @@ class PeerManager { this.seenPeers.set(id, { seq, lastSeen: Date.now(), + key, + ip: ip || (stored ? stored.ip : null), }); return wasNew; @@ -70,6 +72,16 @@ class PeerManager { getSeq() { return this.mySeq; } + + getPeersWithIps() { + const peers = []; + for (const [id, data] of this.seenPeers.entries()) { + if (data.ip) { + peers.push({ id, ip: data.ip }); + } + } + return peers; + } } module.exports = { PeerManager }; diff --git a/src/web/routes.js b/src/web/routes.js index 8a915d9..39b57a1 100644 --- a/src/web/routes.js +++ b/src/web/routes.js @@ -1,6 +1,7 @@ const express = require("express"); const fs = require("fs"); const path = require("path"); +const { ENABLE_CHAT, CHAT_RATE_LIMIT } = require("../config/constants"); const HTML_TEMPLATE = fs.readFileSync( path.join(__dirname, "../../public/index.html"), @@ -8,13 +9,15 @@ const HTML_TEMPLATE = fs.readFileSync( ); const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics) => { + app.use(express.json()); + app.get("/", (req, res) => { const count = peerManager.size; const directPeers = swarm.getSwarm().connections.size; const html = HTML_TEMPLATE .replace(/\{\{COUNT\}\}/g, count) - .replace(/\{\{ID\}\}/g, identity.id.slice(0, 8) + "...") + .replace(/\{\{ID\}\}/g, "..." + identity.id.slice(-8)) .replace(/\{\{DIRECT\}\}/g, directPeers); res.send(html); @@ -34,6 +37,8 @@ const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics) direct: swarm.getSwarm().connections.size, id: identity.id, diagnostics: diagnostics.getStats(), + chatEnabled: ENABLE_CHAT, + peers: peerManager.getPeersWithIps() }); res.write(`data: ${data}\n\n`); @@ -49,9 +54,46 @@ const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics) direct: swarm.getSwarm().connections.size, id: identity.id, diagnostics: diagnostics.getStats(), + chatEnabled: ENABLE_CHAT, + peers: peerManager.getPeersWithIps() }); }); + let chatHistory = []; // Store timestamps of recent messages + + app.post("/api/chat", (req, res) => { + if (!ENABLE_CHAT) { + return res.status(403).json({ error: "Chat disabled" }); + } + + const now = Date.now(); + // Clean up old timestamps (older than 10 seconds) + chatHistory = chatHistory.filter(time => now - time < 10000); + + if (chatHistory.length >= 5) { + return res.status(429).json({ error: "Rate limit exceeded: Max 5 messages per 10 seconds" }); + } + + chatHistory.push(now); + + const { content } = req.body; + if (!content || typeof content !== 'string' || content.length > 140) { + return res.status(400).json({ error: "Invalid content" }); + } + + const msg = { + type: "CHAT", + sender: identity.id, + content: content, + timestamp: Date.now() + }; + + swarm.broadcastChat(msg); + sseManager.broadcast(msg); + + res.json({ success: true }); + }); + app.use(express.static(path.join(__dirname, "../../public"))); } diff --git a/src/web/sse.js b/src/web/sse.js index c80f3cc..27e990e 100644 --- a/src/web/sse.js +++ b/src/web/sse.js @@ -19,6 +19,10 @@ class SSEManager { if (now - this.lastBroadcast < BROADCAST_THROTTLE) return; this.lastBroadcast = now; + this.broadcast(data); + } + + broadcast(data) { const message = JSON.stringify(data); for (const client of this.clients) { client.write(`data: ${message}\n\n`);