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`);