diff options
| -rwxr-xr-x | app.py | 54 | ||||
| -rwxr-xr-x | index.html | 61 | ||||
| -rwxr-xr-x | js/main.js | 91 | ||||
| -rw-r--r-- | js/visualizer.js | 550 | ||||
| -rwxr-xr-x | server_readme.md | 124 | ||||
| -rwxr-xr-x | wss/index.html | 41 | ||||
| -rwxr-xr-x | wss/main.go | 275 | ||||
| -rwxr-xr-x | wss/server | bin | 9114588 -> 9127008 bytes |
8 files changed, 733 insertions, 463 deletions
@@ -1,54 +0,0 @@ -from flask import Flask, render_template, send_from_directory, jsonify
-import os
-
-# Create the Flask application
-app = Flask(__name__,
- static_folder='.',
- static_url_path='')
-
-# Configuration
-class Config:
- DEBUG = True
- SECRET_KEY = os.environ.get('SECRET_KEY', 'development-key')
- # Add more configuration options as needed
-
-app.config.from_object(Config)
-
-# Environment variables to pass to frontend
-def get_env_vars():
- """Return environment variables to be passed to the frontend"""
- return {
- 'thing': 1,
- # Add more variables as needed
- }
-
-# Routes
-@app.route('/')
-def index():
- """Serve the main index.html page"""
- return send_from_directory('.', 'index.html')
-
-# API routes - examples for future expansion
-@app.route('/api/env')
-def api_env():
- """Return environment variables as JSON"""
- return jsonify(get_env_vars())
-
-@app.route('/api/health')
-def health_check():
- """Health check endpoint"""
- return jsonify({'status': 'ok'})
-
-# Error handlers
-@app.errorhandler(404)
-def not_found(e):
- return jsonify({'error': 'Not found'}), 404
-
-@app.errorhandler(500)
-def server_error(e):
- return jsonify({'error': 'Server error'}), 500
-
-if __name__ == '__main__':
- # Get port from environment variable or use 5000 as default
- port = int(os.environ.get('PORT_HTTP', 5000))
- app.run(host='0.0.0.0', port=port)
@@ -6,11 +6,10 @@ <title>Graph Visualizer</title>
<link rel="stylesheet" href="css/skeleton.css">
<link rel="stylesheet" href="css/main.css">
- <link rel="stylesheet" href="css/table.css">
<style>
#visualizer-container {
width: 100%;
- height: 500px;
+ height: max(500px, 60vh);
background: #1a1a2e;
border-radius: 8px;
overflow: hidden;
@@ -24,10 +23,54 @@ display: flex;
gap: 10px;
flex-wrap: wrap;
+ align-items: center;
}
.visualizer-controls button {
margin-bottom: 0;
}
+ .visualizer-controls button:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+ .status-bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 1rem;
+ }
+ #presence-count {
+ color: #888;
+ font-size: 0.9rem;
+ }
+ /* Toast notification styles */
+ #toast-container {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ z-index: 10000;
+ display: flex;
+ flex-direction: column-reverse;
+ gap: 8px;
+ pointer-events: none;
+ }
+ .toast {
+ padding: 10px 18px;
+ border-radius: 6px;
+ color: #fff;
+ font-size: 0.85rem;
+ opacity: 0;
+ transform: translateX(40px);
+ transition: opacity 0.3s, transform 0.3s;
+ pointer-events: auto;
+ }
+ .toast-visible {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ .toast-info { background: rgba(78,205,196,0.9); }
+ .toast-success { background: rgba(46,204,113,0.85); }
+ .toast-error { background: rgba(255,107,107,0.9); }
</style>
</head>
<body>
@@ -44,15 +87,20 @@ <div class="visualizer-controls">
<button id="btn-add-node">Add Node</button>
<button id="btn-add-edge">Add Random Edge</button>
+ <button id="btn-connect-selected" disabled>Connect Two Nodes</button>
+ <button id="btn-delete-selected" disabled>Delete Selected</button>
<button id="btn-reset">Reset Graph</button>
</div>
</section>
- <center>- - -</center>
+ <hr>
<section>
- <div id="server-status">Server status: Connecting...</div>
+ <div class="status-bar">
+ <div id="server-status">Server status: Connecting...</div>
+ <div id="presence-count"></div>
+ </div>
</section>
<footer>
- <p>Drag to rotate | Scroll to zoom | Click nodes to select</p>
+ <p>Drag to rotate | Scroll to zoom | Click nodes to select | Drag nodes to move | Delete key to remove | Select 2 nodes to connect</p>
</footer>
</main>
</body>
@@ -65,9 +113,6 @@ }
}
</script>
-<script type="text/javascript">
- window.env = {'thing': 123123};
-</script>
<script type="module" src="js/visualizer.js"></script>
<script type="text/javascript" src="js/main.js"></script>
</html>
@@ -15,7 +15,7 @@ let dom = { };
// application state
-let ws = null; // WebSocket connection
+// (WebSocket is managed by visualizer.js)
//APP START HERE
@@ -29,7 +29,7 @@ $(document).ready(async function() { log('init cfg');
await initCfg();
- // 3. communicate with any external services if needed, like REST API or websocket
+ // 3. communicate with any external services if needed, like REST API
log('init services');
await initServices();
@@ -54,11 +54,6 @@ function initCfg(){ function initServices(){
- //connect to websocket server
- connectWebSocket();
-
- //grab data from REST API
-
// Load environment variables from a server
return fetch('/api/env')
.then(response => {
@@ -71,42 +66,17 @@ function initServices(){ .then(data => {
env = data;
console.log('Environment loaded:', env);
- updateServerStatus('connected');
return data;
})
.catch(error => {
console.error('Error loading environment:', error);
- updateServerStatus('error');
return env; // Return default
});
}
-// Update server status indicator
-function updateServerStatus(status) {
- if (!dom.serverStatus) return;
-
- switch(status) {
- case 'connecting':
- dom.serverStatus.innerHTML = 'Server status: Connecting...';
- dom.serverStatus.style.color = 'orange';
- break;
- case 'connected':
- dom.serverStatus.innerHTML = 'Server status: Connected';
- dom.serverStatus.style.color = 'green';
- break;
- case 'error':
- dom.serverStatus.innerHTML = 'Server status: Error';
- dom.serverStatus.style.color = 'red';
- break;
- }
-}
-
function initDOM(){
dom.body = $('body')[0];
dom.serverStatus = document.getElementById('server-status');
- if (dom.serverStatus) {
- updateServerStatus('connecting');
- }
}
function initApp(){
@@ -119,60 +89,3 @@ function log(msg, lvl=1){ }
console.log(msg);
}
-
-// Connect to WebSocket server for collaborative editing
-function connectWebSocket() {
- // Check if WebSocket is supported
- if (!window.WebSocket) {
- console.error("WebSocket not supported by browser");
- return;
- }
-
- // Create WebSocket connection
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- const wsUrl = `${protocol}//${window.location.host}/ws`;
-
- try {
- ws = new WebSocket(wsUrl);
-
- // WebSocket event handlers
- ws.onopen = function(e) {
- console.log("WebSocket connection established");
- updateServerStatus("connected");
- };
-
- ws.onmessage = function(e) {
- try {
- const data = JSON.parse(e.data);
- handleWebSocketMessage(data);
- } catch (err) {
- console.error("Error processing WebSocket message:", err);
- }
- };
-
- ws.onclose = function(e) {
- console.log("WebSocket connection closed");
- // Try to reconnect after delay
- setTimeout(connectWebSocket, 5000);
- };
-
- ws.onerror = function(e) {
- console.error("WebSocket error:", e);
- updateServerStatus("error");
- };
- } catch (err) {
- console.error("Failed to create WebSocket connection:", err);
- }
-}
-
-// Handle incoming WebSocket messages (to be extended for graph operations)
-function handleWebSocketMessage(data) {
- switch (data.type) {
- case 'graphUpdate':
- // TODO: Handle graph updates from other clients
- console.log('Received graph update:', data);
- break;
- default:
- console.log('Unknown message type:', data.type);
- }
-}
diff --git a/js/visualizer.js b/js/visualizer.js index b505c59..bfde28f 100644 --- a/js/visualizer.js +++ b/js/visualizer.js @@ -1,7 +1,40 @@ import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-// Graph data structure (synced via CRDT)
+// --- Utility: throttle function ---
+function throttle(fn, limit) {
+ let lastCall = 0;
+ let timeout = null;
+ return function (...args) {
+ const now = Date.now();
+ const remaining = limit - (now - lastCall);
+ if (remaining <= 0) {
+ lastCall = now;
+ fn.apply(this, args);
+ } else if (!timeout) {
+ timeout = setTimeout(() => {
+ lastCall = Date.now();
+ timeout = null;
+ fn.apply(this, args);
+ }, remaining);
+ }
+ };
+}
+
+// DOM references
+const dom = {
+ container: null,
+ btnAddNode: null,
+ btnAddEdge: null,
+ btnReset: null,
+ btnDeleteSelected: null,
+ btnConnectSelected: null,
+ serverStatus: null,
+ presenceCount: null,
+ toastContainer: null
+};
+
+// Graph data structure (synced via WebSocket)
const graph = {
nodes: [],
edges: []
@@ -11,32 +44,86 @@ const graph = { let scene, camera, renderer, controls;
let nodeMeshes = [];
let edgeLines = [];
+let nodeLabels = []; // CSS2D or sprite labels
let raycaster, mouse;
-let selectedNode = null;
+let selectedNodes = []; // support multi-select (up to 2 for edge creation)
+
+// Shared geometry for all nodes (performance: avoid duplicating geometry)
+const sharedNodeGeometry = new THREE.SphereGeometry(0.4, 32, 32);
+
+// Drag state
+let isDragging = false;
+let draggedNode = null;
+let dragPlane = null;
+let dragOffset = new THREE.Vector3();
+let mouseDownPos = new THREE.Vector2();
+const CLICK_THRESHOLD = 3; // pixels — below this, treat as click not drag
// WebSocket connection
let ws = null;
let wsConnected = false;
+// Throttled moveNode sender (30 msgs/sec max)
+const sendMoveThrottled = throttle((nodeId, x, y, z) => {
+ sendToServer({
+ type: 'moveNode',
+ node: { id: nodeId, x, y, z }
+ });
+}, 33);
+
// Colors
const colors = {
node: 0x4ecdc4,
nodeHover: 0xff6b6b,
nodeSelected: 0xffe66d,
+ nodeSelectedSecond: 0xff9f43,
edge: 0x95a5a6,
background: 0x1a1a2e
};
+// --- Toast notification system ---
+function showToast(message, type = 'info', duration = 3000) {
+ if (!dom.toastContainer) return;
+ const toast = document.createElement('div');
+ toast.className = `toast toast-${type}`;
+ toast.textContent = message;
+ dom.toastContainer.appendChild(toast);
+ // Trigger animation
+ requestAnimationFrame(() => toast.classList.add('toast-visible'));
+ setTimeout(() => {
+ toast.classList.remove('toast-visible');
+ toast.addEventListener('transitionend', () => toast.remove());
+ }, duration);
+}
+
+// Initialize the visualizer
+function initDOM() {
+ dom.container = document.getElementById('visualizer-container');
+ dom.btnAddNode = document.getElementById('btn-add-node');
+ dom.btnAddEdge = document.getElementById('btn-add-edge');
+ dom.btnReset = document.getElementById('btn-reset');
+ dom.btnDeleteSelected = document.getElementById('btn-delete-selected');
+ dom.btnConnectSelected = document.getElementById('btn-connect-selected');
+ dom.serverStatus = document.getElementById('server-status');
+ dom.presenceCount = document.getElementById('presence-count');
+
+ // Create toast container
+ dom.toastContainer = document.createElement('div');
+ dom.toastContainer.id = 'toast-container';
+ document.body.appendChild(dom.toastContainer);
+}
+
// Initialize the visualizer
function init() {
- const container = document.getElementById('visualizer-container');
- if (!container) {
+ initDOM();
+
+ if (!dom.container) {
console.error('Visualizer container not found');
return;
}
- const width = container.clientWidth;
- const height = container.clientHeight;
+ const width = dom.container.clientWidth;
+ const height = dom.container.clientHeight;
// Scene
scene = new THREE.Scene();
@@ -50,7 +137,7 @@ function init() { renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
- container.appendChild(renderer.domElement);
+ dom.container.appendChild(renderer.domElement);
// Controls
controls = new OrbitControls(camera, renderer.domElement);
@@ -80,17 +167,27 @@ function init() { // Graph will be populated from server via WebSocket
+ // Drag plane (invisible, used for projecting mouse to 3D)
+ dragPlane = new THREE.Plane();
+
// Event listeners
window.addEventListener('resize', onWindowResize);
- container.addEventListener('click', onMouseClick);
- container.addEventListener('mousemove', onMouseMove);
+ dom.container.addEventListener('mousedown', onMouseDown);
+ dom.container.addEventListener('mousemove', onMouseMove);
+ dom.container.addEventListener('mouseup', onMouseUp);
+ dom.container.addEventListener('mouseleave', onMouseUp);
// Button controls
- document.getElementById('btn-add-node').addEventListener('click', addRandomNode);
- document.getElementById('btn-add-edge').addEventListener('click', addRandomEdge);
- document.getElementById('btn-reset').addEventListener('click', resetGraph);
+ dom.btnAddNode.addEventListener('click', addRandomNode);
+ dom.btnAddEdge.addEventListener('click', addRandomEdge);
+ dom.btnReset.addEventListener('click', resetGraph);
+ if (dom.btnDeleteSelected) dom.btnDeleteSelected.addEventListener('click', deleteSelectedNode);
+ if (dom.btnConnectSelected) dom.btnConnectSelected.addEventListener('click', connectSelectedNodes);
- // Connect to WebSocket for CRDT sync
+ // Keyboard shortcuts
+ window.addEventListener('keydown', onKeyDown);
+
+ // Connect to WebSocket for sync
connectWebSocket();
// Start animation loop
@@ -135,36 +232,59 @@ function connectWebSocket() { }
function updateConnectionStatus(connected) {
- const status = document.getElementById('server-status');
- if (status) {
- status.textContent = connected ? 'Connected - Collaborative mode' : 'Disconnected - Reconnecting...';
- status.style.color = connected ? '#4ecdc4' : '#ff6b6b';
- }
+ if (!dom.serverStatus) return;
+ dom.serverStatus.textContent = connected
+ ? 'Connected - Collaborative mode'
+ : 'Disconnected - Reconnecting...';
+ dom.serverStatus.style.color = connected ? '#4ecdc4' : '#ff6b6b';
+}
+
+function updatePresenceCount(count) {
+ if (!dom.presenceCount) return;
+ dom.presenceCount.textContent = `${count} user${count !== 1 ? 's' : ''} online`;
}
// Handle messages from server
function handleServerMessage(msg) {
switch (msg.type) {
case 'fullSync':
- // Full graph sync - rebuild entire visualization
if (msg.graph) {
syncFullGraph(msg.graph);
}
break;
case 'addNode':
- // Another client added a node
if (msg.node) {
addNodeLocal(msg.node.id, msg.node.x, msg.node.y, msg.node.z);
+ showToast(`Node ${msg.node.id} added`, 'success', 2000);
}
break;
case 'addEdge':
- // Another client added an edge
if (msg.edge) {
addEdgeLocal(msg.edge.from, msg.edge.to);
}
break;
+
+ case 'removeEdge':
+ if (msg.edge) {
+ removeEdgeLocal(msg.edge.from, msg.edge.to);
+ }
+ break;
+
+ case 'moveNode':
+ if (msg.node) {
+ moveNodeLocal(msg.node.id, msg.node.x, msg.node.y, msg.node.z);
+ }
+ break;
+
+ case 'connectionCount':
+ updatePresenceCount(msg.count);
+ break;
+
+ case 'error':
+ showToast(msg.message || 'Server error', 'error');
+ break;
}
}
@@ -172,14 +292,32 @@ function handleServerMessage(msg) { function syncFullGraph(serverGraph) {
console.log('Syncing full graph:', serverGraph.nodes?.length || 0, 'nodes,', serverGraph.edges?.length || 0, 'edges');
- // Clear current visualization
- nodeMeshes.forEach(mesh => scene.remove(mesh));
- edgeLines.forEach(line => scene.remove(line));
+ // Dispose old Three.js resources to prevent GPU memory leaks
+ nodeMeshes.forEach(mesh => {
+ scene.remove(mesh);
+ mesh.material.dispose();
+ // geometry is shared, don't dispose it
+ });
+ edgeLines.forEach(line => {
+ scene.remove(line);
+ line.geometry.dispose();
+ line.material.dispose();
+ });
+ nodeLabels.forEach(label => {
+ scene.remove(label);
+ if (label.material) {
+ if (label.material.map) label.material.map.dispose();
+ label.material.dispose();
+ }
+ });
+
nodeMeshes = [];
edgeLines = [];
+ nodeLabels = [];
graph.nodes = [];
graph.edges = [];
- selectedNode = null;
+ selectedNodes = [];
+ updateSelectionUI();
// Rebuild from server state
if (serverGraph.nodes) {
@@ -278,6 +416,36 @@ function addStarfield() { scene.add(stars);
}
+// Create a text sprite for node labels
+function createTextSprite(text) {
+ const canvas = document.createElement('canvas');
+ const size = 128;
+ canvas.width = size;
+ canvas.height = size;
+ const ctx = canvas.getContext('2d');
+
+ ctx.font = 'Bold 48px Arial';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ // Text with outline for readability
+ ctx.strokeStyle = 'rgba(0,0,0,0.8)';
+ ctx.lineWidth = 6;
+ ctx.strokeText(text, size / 2, size / 2);
+ ctx.fillStyle = '#ffffff';
+ ctx.fillText(text, size / 2, size / 2);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ const material = new THREE.SpriteMaterial({
+ map: texture,
+ transparent: true,
+ depthTest: false
+ });
+ const sprite = new THREE.Sprite(material);
+ sprite.scale.set(0.6, 0.6, 1);
+ return sprite;
+}
+
// Local-only functions (apply server state to visualization)
function addNodeLocal(id, x, y, z) {
// Ensure we don't duplicate
@@ -286,19 +454,25 @@ function addNodeLocal(id, x, y, z) { // Create node data
graph.nodes.push({ id, x, y, z });
- // Create visual representation
- const geometry = new THREE.SphereGeometry(0.4, 32, 32);
+ // Create visual representation (shared geometry for performance)
const material = new THREE.MeshPhongMaterial({
color: colors.node,
shininess: 100
});
- const mesh = new THREE.Mesh(geometry, material);
+ const mesh = new THREE.Mesh(sharedNodeGeometry, material);
mesh.position.set(x, y, z);
mesh.userData.nodeId = id;
scene.add(mesh);
nodeMeshes.push(mesh);
+ // Add floating label
+ const label = createTextSprite(String(id));
+ label.position.set(x, y + 0.7, z);
+ label.userData.nodeId = id;
+ scene.add(label);
+ nodeLabels.push(label);
+
// Flash effect for new nodes
material.emissive = new THREE.Color(0x4ecdc4);
setTimeout(() => { material.emissive = new THREE.Color(0x000000); }, 300);
@@ -340,6 +514,73 @@ function addEdgeLocal(fromId, toId) { edgeLines.push(line);
}
+function removeEdgeLocal(fromId, toId) {
+ // Remove from data
+ const idx = graph.edges.findIndex(e =>
+ (e.from === fromId && e.to === toId) ||
+ (e.from === toId && e.to === fromId)
+ );
+ if (idx < 0) return;
+ graph.edges.splice(idx, 1);
+
+ // Remove visual
+ const lineIdx = edgeLines.findIndex(l =>
+ (l.userData.fromId === fromId && l.userData.toId === toId) ||
+ (l.userData.fromId === toId && l.userData.toId === fromId)
+ );
+ if (lineIdx >= 0) {
+ const line = edgeLines[lineIdx];
+ scene.remove(line);
+ line.geometry.dispose();
+ line.material.dispose();
+ edgeLines.splice(lineIdx, 1);
+ }
+}
+
+function moveNodeLocal(id, x, y, z) {
+ // Update graph data
+ const node = graph.nodes.find(n => n.id === id);
+ if (!node) return;
+
+ node.x = x;
+ node.y = y;
+ node.z = z;
+
+ // Update mesh position
+ const mesh = nodeMeshes.find(m => m.userData.nodeId === id);
+ if (mesh) {
+ mesh.position.set(x, y, z);
+ }
+
+ // Update label position
+ const label = nodeLabels.find(l => l.userData.nodeId === id);
+ if (label) {
+ label.position.set(x, y + 0.7, z);
+ }
+
+ // Update connected edges
+ updateEdgesForNode(id);
+}
+
+function updateEdgesForNode(nodeId) {
+ edgeLines.forEach(line => {
+ const fromId = line.userData.fromId;
+ const toId = line.userData.toId;
+
+ if (fromId === nodeId || toId === nodeId) {
+ const fromNode = graph.nodes.find(n => n.id === fromId);
+ const toNode = graph.nodes.find(n => n.id === toId);
+
+ if (fromNode && toNode) {
+ const positions = line.geometry.attributes.position;
+ positions.setXYZ(0, fromNode.x, fromNode.y, fromNode.z);
+ positions.setXYZ(1, toNode.x, toNode.y, toNode.z);
+ positions.needsUpdate = true;
+ }
+ }
+ });
+}
+
// Server-facing functions (send operations to server)
function requestAddNode(x, y, z) {
sendToServer({
@@ -355,6 +596,111 @@ function requestAddEdge(fromId, toId) { });
}
+function requestRemoveNode(id) {
+ sendToServer({
+ type: 'removeNode',
+ node: { id }
+ });
+}
+
+function requestRemoveEdge(fromId, toId) {
+ sendToServer({
+ type: 'removeEdge',
+ edge: { from: fromId, to: toId }
+ });
+}
+
+// Selection management (supports 1-2 nodes for edge creation)
+function clearSelection() {
+ selectedNodes.forEach(mesh => {
+ mesh.material.color.setHex(colors.node);
+ });
+ selectedNodes = [];
+ updateSelectionUI();
+}
+
+function toggleNodeSelection(mesh) {
+ const idx = selectedNodes.indexOf(mesh);
+ if (idx >= 0) {
+ // Deselect
+ mesh.material.color.setHex(colors.node);
+ selectedNodes.splice(idx, 1);
+ } else {
+ if (selectedNodes.length >= 2) {
+ // Deselect oldest
+ selectedNodes[0].material.color.setHex(colors.node);
+ selectedNodes.shift();
+ }
+ selectedNodes.push(mesh);
+ mesh.material.color.setHex(
+ selectedNodes.length === 1 ? colors.nodeSelected : colors.nodeSelectedSecond
+ );
+ // Re-color first if we now have two
+ if (selectedNodes.length === 2) {
+ selectedNodes[0].material.color.setHex(colors.nodeSelected);
+ selectedNodes[1].material.color.setHex(colors.nodeSelectedSecond);
+ }
+ }
+ updateSelectionUI();
+}
+
+function updateSelectionUI() {
+ const hasSelection = selectedNodes.length > 0;
+ const hasPair = selectedNodes.length === 2;
+ if (dom.btnDeleteSelected) {
+ dom.btnDeleteSelected.disabled = !hasSelection;
+ dom.btnDeleteSelected.textContent = hasSelection
+ ? `Delete Node ${selectedNodes.map(m => m.userData.nodeId).join(', ')}`
+ : 'Delete Selected';
+ }
+ if (dom.btnConnectSelected) {
+ dom.btnConnectSelected.disabled = !hasPair;
+ dom.btnConnectSelected.textContent = hasPair
+ ? `Connect ${selectedNodes[0].userData.nodeId} ↔ ${selectedNodes[1].userData.nodeId}`
+ : 'Connect Two Nodes';
+ }
+}
+
+function deleteSelectedNode() {
+ if (selectedNodes.length === 0) {
+ showToast('Select a node first', 'info');
+ return;
+ }
+ // Delete all selected nodes
+ selectedNodes.forEach(mesh => {
+ requestRemoveNode(mesh.userData.nodeId);
+ });
+ selectedNodes = [];
+ updateSelectionUI();
+}
+
+function connectSelectedNodes() {
+ if (selectedNodes.length !== 2) {
+ showToast('Select exactly 2 nodes to connect', 'info');
+ return;
+ }
+ const idA = selectedNodes[0].userData.nodeId;
+ const idB = selectedNodes[1].userData.nodeId;
+ requestAddEdge(idA, idB);
+ clearSelection();
+}
+
+function onKeyDown(event) {
+ // Delete or Backspace to remove selected node(s)
+ if (event.key === 'Delete' || event.key === 'Backspace') {
+ // Don't intercept if user is typing in an input
+ if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') return;
+ if (selectedNodes.length > 0) {
+ event.preventDefault();
+ deleteSelectedNode();
+ }
+ }
+ // Escape to clear selection
+ if (event.key === 'Escape') {
+ clearSelection();
+ }
+}
+
// UI button handlers
function addRandomNode() {
const range = 5;
@@ -362,98 +708,146 @@ function addRandomNode() { const y = (Math.random() - 0.5) * range * 2;
const z = (Math.random() - 0.5) * range * 2;
+ // Remember current node count to detect when server confirms the new node
+ const prevCount = graph.nodes.length;
+ const randomTarget = graph.nodes.length > 0
+ ? graph.nodes[Math.floor(Math.random() * graph.nodes.length)].id
+ : null;
+
requestAddNode(x, y, z);
- // Also request an edge to a random existing node
- if (graph.nodes.length > 0) {
- // We'll add the edge after the node is confirmed by server
- // For now, schedule it with a small delay
- const randomId = graph.nodes[Math.floor(Math.random() * graph.nodes.length)].id;
- setTimeout(() => {
- if (graph.nodes.length > 1) {
+ // Wait for confirmation then add edge, with timeout safety
+ if (randomTarget !== null) {
+ const startTime = Date.now();
+ const checkInterval = setInterval(() => {
+ if (graph.nodes.length > prevCount) {
+ clearInterval(checkInterval);
const newNodeId = graph.nodes[graph.nodes.length - 1].id;
- requestAddEdge(newNodeId, randomId);
+ if (newNodeId !== randomTarget) {
+ requestAddEdge(newNodeId, randomTarget);
+ }
+ } else if (Date.now() - startTime > 2000) {
+ clearInterval(checkInterval); // give up after 2s
}
- }, 100);
+ }, 50);
}
}
function addRandomEdge() {
- if (graph.nodes.length < 2) return;
-
- const fromNode = graph.nodes[Math.floor(Math.random() * graph.nodes.length)];
- let toNode = graph.nodes[Math.floor(Math.random() * graph.nodes.length)];
-
- // Make sure we don't connect a node to itself
- while (toNode.id === fromNode.id) {
- toNode = graph.nodes[Math.floor(Math.random() * graph.nodes.length)];
+ if (graph.nodes.length < 2) {
+ showToast('Need at least 2 nodes', 'info');
+ return;
}
- requestAddEdge(fromNode.id, toNode.id);
+ // Safe random selection without infinite loop
+ const fromIdx = Math.floor(Math.random() * graph.nodes.length);
+ let toIdx = Math.floor(Math.random() * (graph.nodes.length - 1));
+ if (toIdx >= fromIdx) toIdx++; // skip the fromIdx slot
+
+ requestAddEdge(graph.nodes[fromIdx].id, graph.nodes[toIdx].id);
}
function resetGraph() {
sendToServer({ type: 'reset' });
+ showToast('Graph reset', 'info');
}
function onWindowResize() {
- const container = document.getElementById('visualizer-container');
- const width = container.clientWidth;
- const height = container.clientHeight;
+ const width = dom.container.clientWidth;
+ const height = dom.container.clientHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
}
-function onMouseMove(event) {
- const container = document.getElementById('visualizer-container');
- const rect = container.getBoundingClientRect();
+function onMouseDown(event) {
+ const rect = dom.container.getBoundingClientRect();
+ mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
+ mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
+ mouseDownPos.set(event.clientX, event.clientY);
+ raycaster.setFromCamera(mouse, camera);
+ const intersects = raycaster.intersectObjects(nodeMeshes);
+
+ if (intersects.length > 0) {
+ isDragging = true;
+ draggedNode = intersects[0].object;
+ controls.enabled = false;
+
+ // Create a drag plane facing the camera at the node's position
+ const cameraDirection = new THREE.Vector3();
+ camera.getWorldDirection(cameraDirection);
+ dragPlane = new THREE.Plane();
+ dragPlane.setFromNormalAndCoplanarPoint(cameraDirection, draggedNode.position);
+
+ // Compute offset between intersection point and node center so it doesn't snap
+ const intersection = new THREE.Vector3();
+ raycaster.ray.intersectPlane(dragPlane, intersection);
+ dragOffset.subVectors(draggedNode.position, intersection);
+
+ dom.container.style.cursor = 'grabbing';
+ }
+}
+
+function onMouseMove(event) {
+ const rect = dom.container.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
- // Check for hover
+ if (isDragging && draggedNode) {
+ raycaster.setFromCamera(mouse, camera);
+ const intersection = new THREE.Vector3();
+ if (raycaster.ray.intersectPlane(dragPlane, intersection)) {
+ intersection.add(dragOffset);
+
+ const nodeId = draggedNode.userData.nodeId;
+ moveNodeLocal(nodeId, intersection.x, intersection.y, intersection.z);
+
+ // Broadcast to other clients (throttled to ~30 msgs/sec)
+ sendMoveThrottled(nodeId, intersection.x, intersection.y, intersection.z);
+ }
+ dom.container.style.cursor = 'grabbing';
+ return;
+ }
+
+ // Hover logic (only when not dragging)
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(nodeMeshes);
// Reset all non-selected nodes to default color
nodeMeshes.forEach(mesh => {
- if (mesh !== selectedNode) {
+ if (!selectedNodes.includes(mesh)) {
mesh.material.color.setHex(colors.node);
}
});
// Highlight hovered node
- if (intersects.length > 0 && intersects[0].object !== selectedNode) {
+ if (intersects.length > 0 && !selectedNodes.includes(intersects[0].object)) {
intersects[0].object.material.color.setHex(colors.nodeHover);
- container.style.cursor = 'pointer';
+ dom.container.style.cursor = 'pointer';
} else {
- container.style.cursor = 'grab';
+ dom.container.style.cursor = 'grab';
}
}
-function onMouseClick(event) {
- const container = document.getElementById('visualizer-container');
- const rect = container.getBoundingClientRect();
-
- mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
- mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
-
- raycaster.setFromCamera(mouse, camera);
- const intersects = raycaster.intersectObjects(nodeMeshes);
-
- // Deselect previous node
- if (selectedNode) {
- selectedNode.material.color.setHex(colors.node);
- }
+function onMouseUp(event) {
+ if (isDragging && draggedNode) {
+ // Check if it was a click (barely moved) vs a real drag
+ const dx = event.clientX - mouseDownPos.x;
+ const dy = event.clientY - mouseDownPos.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+
+ if (dist < CLICK_THRESHOLD) {
+ // Treat as click — toggle selection on this node
+ toggleNodeSelection(draggedNode);
+ console.log('Selected nodes:', selectedNodes.map(m => m.userData.nodeId));
+ }
- if (intersects.length > 0) {
- selectedNode = intersects[0].object;
- selectedNode.material.color.setHex(colors.nodeSelected);
- console.log('Selected node:', selectedNode.userData.nodeId);
- } else {
- selectedNode = null;
+ isDragging = false;
+ draggedNode = null;
+ controls.enabled = true;
+ dom.container.style.cursor = 'grab';
}
}
@@ -480,4 +874,4 @@ if (document.readyState === 'loading') { }
// Export for potential external use
-export { graph, requestAddNode, requestAddEdge, resetGraph };
+export { graph, requestAddNode, requestAddEdge, requestRemoveNode, requestRemoveEdge, resetGraph };
diff --git a/server_readme.md b/server_readme.md deleted file mode 100755 index 604c398..0000000 --- a/server_readme.md +++ /dev/null @@ -1,124 +0,0 @@ -# Flask Web Server for Simple Website
-
-This is a simple, expandable Flask web server designed to serve a static website with HTML, CSS, and JavaScript files. The server is designed to be easily expanded with new routes and functionality.
-
-## Setup Instructions
-
-### Prerequisites
-- Python 3.6 or higher
-- pip (Python package manager)
-
-### Installation
-
-1. Install required packages:
- ```
- pip install -r requirements.txt
- ```
-
-### Running the Server
-
-1. Start the Flask development server:
- ```
- python app.py
- ```
-
-2. Access the website at [http://localhost:5000](http://localhost:5000)
-
-## Project Structure
-
-- `app.py` - The main Flask application
-- `requirements.txt` - Python dependencies
-- `index.html` - Main HTML file
-- `css/` - CSS stylesheets
-- `js/` - JavaScript files
-
-## How to Expand the Server
-
-### Adding New Routes
-
-To add a new page or API endpoint, add a route to `app.py`:
-
-```python
-@app.route('/new-page')
-def new_page():
- return send_from_directory('.', 'new-page.html')
-```
-
-### Adding API Endpoints
-
-For JSON APIs, add a route that returns a JSON response:
-
-```python
-@app.route('/api/data')
-def api_data():
- data = {
- 'key': 'value',
- 'items': [1, 2, 3]
- }
- return jsonify(data)
-```
-
-### Adding Database Support
-
-To add a database, you can use Flask-SQLAlchemy:
-
-1. Install Flask-SQLAlchemy:
- ```
- pip install Flask-SQLAlchemy
- ```
-
-2. Update your `app.py` to include database configuration:
- ```python
- from flask_sqlalchemy import SQLAlchemy
-
- app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///site.db'
- db = SQLAlchemy(app)
-
- class User(db.Model):
- id = db.Column(db.Integer, primary_key=True)
- username = db.Column(db.String(80), unique=True, nullable=False)
- ```
-
-### Adding User Authentication
-
-For user authentication, you can use Flask-Login:
-
-1. Install Flask-Login:
- ```
- pip install Flask-Login
- ```
-
-2. Configure it in your application:
- ```python
- from flask_login import LoginManager, UserMixin
-
- login_manager = LoginManager(app)
- login_manager.login_view = 'login'
- ```
-
-### Environment Variables
-
-The server passes environment variables to the frontend. To add new environment variables:
-
-1. Update the `get_env_vars()` function in `app.py`:
- ```python
- def get_env_vars():
- return {
- 'thing': 1,
- 'apiUrl': 'http://api.example.com',
- 'newVariable': 'value'
- }
- ```
-
-## Production Deployment
-
-For production deployment, consider:
-
-1. Using a WSGI server like Gunicorn:
- ```
- pip install gunicorn
- gunicorn -w 4 -b 0.0.0.0:5000 app:app
- ```
-
-2. Setting up a proper web server like Nginx as a reverse proxy
-3. Setting environment variables for production configuration
diff --git a/wss/index.html b/wss/index.html deleted file mode 100755 index 79d9b9c..0000000 --- a/wss/index.html +++ /dev/null @@ -1,41 +0,0 @@ -<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Template</title>
- <link rel="stylesheet" href="css/skeleton.css">
- <link rel="stylesheet" href="css/main.css">
- <link rel="stylesheet" href="css/table.css">
-</head>
-<body>
- <header>
- <h2>.</h2>
- <section>
- <p> </p>
- </section>
- </header>
- <main>
- <center>- - -</center>
- <section>
- <h5>Collaborative Editable Table</h5>
- <div class="editing-status connecting" id="editing-status">WebSocket: Connecting...</div>
- <p>Click on any cell to edit. Changes will be synced with other users in real-time.</p>
- <table id="thetable">
- </table>
- </section>
- <center>- - -</center>
- <section>
- <div id="server-status">Server status: Connecting...</div>
- </section>
- <footer>
- <p>Last updated ...</p>
- </footer>
- </main>
-</body>
-<script type = "text/javascript" src = "js/jquery-3.7.1.min.js"></script>
-<script type = "text/javascript">
- window.env = {'thing': 123123};
-</script>
-<script type = "text/javascript" src = "js/main.js"></script>
-</html>
diff --git a/wss/main.go b/wss/main.go index a3ea063..5c270ba 100755 --- a/wss/main.go +++ b/wss/main.go @@ -5,6 +5,7 @@ import ( "flag"
"fmt"
"log"
+ "math"
"net/http"
"os"
"sync"
@@ -13,11 +14,25 @@ import ( "github.com/gorilla/websocket"
)
+const (
+ // Time allowed to write a message to the peer
+ writeWait = 10 * time.Second
+
+ // Time allowed to read the next pong message from the peer
+ pongWait = 60 * time.Second
+
+ // Send pings to peer with this period (must be less than pongWait)
+ pingPeriod = (pongWait * 9) / 10
+
+ // Maximum message size allowed from peer
+ maxMessageSize = 4096
+)
+
// Configuration for the server
type Config struct {
- Debug bool
- Port int
- StaticDir string
+ Debug bool
+ Port int
+ StaticDir string
}
// Environment variables to pass to frontend
@@ -76,18 +91,39 @@ type Hub struct { // Message types
const (
- MessageTypeAddNode = "addNode"
- MessageTypeAddEdge = "addEdge"
- MessageTypeRemoveNode = "removeNode"
- MessageTypeFullSync = "fullSync"
+ MessageTypeAddNode = "addNode"
+ MessageTypeAddEdge = "addEdge"
+ MessageTypeRemoveNode = "removeNode"
+ MessageTypeRemoveEdge = "removeEdge"
+ MessageTypeMoveNode = "moveNode"
+ MessageTypeFullSync = "fullSync"
+ MessageTypeConnectionCount = "connectionCount"
+ MessageTypeError = "error"
)
// WebSocket message format
type WebSocketMessage struct {
- Type string `json:"type"`
- Node *Node `json:"node,omitempty"`
- Edge *Edge `json:"edge,omitempty"`
- Graph *Graph `json:"graph,omitempty"`
+ Type string `json:"type"`
+ Node *Node `json:"node,omitempty"`
+ Edge *Edge `json:"edge,omitempty"`
+ Graph *Graph `json:"graph,omitempty"`
+ Count int `json:"count,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+// findNodeByID returns the index of a node by ID, or -1 if not found
+func (g *Graph) findNodeByID(id int) int {
+ for i, n := range g.Nodes {
+ if n.ID == id {
+ return i
+ }
+ }
+ return -1
+}
+
+// hasNodeID returns true if a node with the given ID exists
+func (g *Graph) hasNodeID(id int) bool {
+ return g.findNodeByID(id) >= 0
}
var hub = Hub{
@@ -119,6 +155,30 @@ func createInitialGraph() Graph { return Graph{Nodes: nodes, Edges: edges}
}
+// broadcastToAll sends a pre-marshaled message to all connections
+func (h *Hub) broadcastToAll(data []byte) {
+ for conn := range h.connections {
+ select {
+ case conn.send <- data:
+ default:
+ close(conn.send)
+ delete(h.connections, conn)
+ }
+ }
+}
+
+// broadcastConnectionCount sends the current connection count to all clients
+func (h *Hub) broadcastConnectionCount() {
+ msg := WebSocketMessage{
+ Type: MessageTypeConnectionCount,
+ Count: len(h.connections),
+ }
+ data, err := json.Marshal(msg)
+ if err == nil {
+ h.broadcastToAll(data)
+ }
+}
+
func (h *Hub) run() {
for {
select {
@@ -126,24 +186,28 @@ func (h *Hub) run() { h.connections[conn] = true
log.Printf("Client connected. Total connections: %d", len(h.connections))
- // Send current graph state to new connection
+ // Send current graph state to new connection (marshal under lock)
h.graphMutex.RLock()
fullSync := WebSocketMessage{
Type: MessageTypeFullSync,
Graph: &h.graph,
}
+ data, err := json.Marshal(fullSync)
h.graphMutex.RUnlock()
- data, err := json.Marshal(fullSync)
if err == nil {
conn.send <- data
}
+ // Notify all clients of connection count
+ h.broadcastConnectionCount()
+
case conn := <-h.unregister:
if _, ok := h.connections[conn]; ok {
delete(h.connections, conn)
close(conn.send)
log.Printf("Client disconnected. Total connections: %d", len(h.connections))
+ h.broadcastConnectionCount()
}
case message := <-h.broadcast:
@@ -154,40 +218,40 @@ func (h *Hub) run() { continue
}
- response := h.handleOperation(msg)
- if response == nil {
- continue
- }
-
- // Broadcast the response to all connections
- data, err := json.Marshal(response)
- if err != nil {
- log.Printf("Error marshaling response: %v", err)
+ // handleOperation now returns pre-marshaled bytes (serialized under lock)
+ data := h.handleOperation(msg)
+ if data == nil {
continue
}
- for conn := range h.connections {
- select {
- case conn.send <- data:
- default:
- close(conn.send)
- delete(h.connections, conn)
- }
- }
+ h.broadcastToAll(data)
}
}
}
-// handleOperation processes graph operations and returns the message to broadcast
-func (h *Hub) handleOperation(msg WebSocketMessage) *WebSocketMessage {
+// isValidCoord checks that a coordinate is a finite number
+func isValidCoord(v float64) bool {
+ return !math.IsNaN(v) && !math.IsInf(v, 0)
+}
+
+// handleOperation processes graph operations and returns pre-marshaled JSON bytes.
+// Marshaling happens under the graph lock to prevent TOCTOU races.
+func (h *Hub) handleOperation(msg WebSocketMessage) []byte {
h.graphMutex.Lock()
defer h.graphMutex.Unlock()
+ var response *WebSocketMessage
+
switch msg.Type {
case MessageTypeAddNode:
if msg.Node == nil {
return nil
}
+ // Validate coordinates
+ if !isValidCoord(msg.Node.X) || !isValidCoord(msg.Node.Y) || !isValidCoord(msg.Node.Z) {
+ log.Printf("Rejected addNode: invalid coordinates")
+ return nil
+ }
// Assign server-side ID to avoid conflicts
newID := 0
for _, n := range h.graph.Nodes {
@@ -203,16 +267,16 @@ func (h *Hub) handleOperation(msg WebSocketMessage) *WebSocketMessage { }
h.graph.Nodes = append(h.graph.Nodes, node)
log.Printf("Added node %d at (%.2f, %.2f, %.2f)", newID, node.X, node.Y, node.Z)
- return &WebSocketMessage{Type: MessageTypeAddNode, Node: &node}
+ response = &WebSocketMessage{Type: MessageTypeAddNode, Node: &node}
case MessageTypeAddEdge:
if msg.Edge == nil {
return nil
}
- // Validate edge
- if msg.Edge.From < 0 || msg.Edge.From >= len(h.graph.Nodes) ||
- msg.Edge.To < 0 || msg.Edge.To >= len(h.graph.Nodes) ||
+ // Validate edge endpoints exist by ID (not index)
+ if !h.graph.hasNodeID(msg.Edge.From) || !h.graph.hasNodeID(msg.Edge.To) ||
msg.Edge.From == msg.Edge.To {
+ log.Printf("Rejected addEdge %d->%d: invalid node IDs", msg.Edge.From, msg.Edge.To)
return nil
}
// Check for duplicate
@@ -225,16 +289,21 @@ func (h *Hub) handleOperation(msg WebSocketMessage) *WebSocketMessage { edge := Edge{From: msg.Edge.From, To: msg.Edge.To}
h.graph.Edges = append(h.graph.Edges, edge)
log.Printf("Added edge %d -> %d", edge.From, edge.To)
- return &WebSocketMessage{Type: MessageTypeAddEdge, Edge: &edge}
+ response = &WebSocketMessage{Type: MessageTypeAddEdge, Edge: &edge}
case MessageTypeRemoveNode:
- if msg.Node == nil || msg.Node.ID < 0 || msg.Node.ID >= len(h.graph.Nodes) {
+ if msg.Node == nil {
return nil
}
- // For simplicity, mark node as removed by setting special coordinates
- // A full implementation would handle ID remapping
nodeID := msg.Node.ID
- // Remove edges connected to this node
+ idx := h.graph.findNodeByID(nodeID)
+ if idx < 0 {
+ log.Printf("Rejected removeNode %d: not found", nodeID)
+ return nil
+ }
+ // Remove the node from the slice
+ h.graph.Nodes = append(h.graph.Nodes[:idx], h.graph.Nodes[idx+1:]...)
+ // Remove all edges connected to this node
newEdges := []Edge{}
for _, e := range h.graph.Edges {
if e.From != nodeID && e.To != nodeID {
@@ -242,17 +311,67 @@ func (h *Hub) handleOperation(msg WebSocketMessage) *WebSocketMessage { }
}
h.graph.Edges = newEdges
- log.Printf("Removed node %d", nodeID)
+ log.Printf("Removed node %d and its edges", nodeID)
// Send full sync after removal for simplicity
- return &WebSocketMessage{Type: MessageTypeFullSync, Graph: &h.graph}
+ response = &WebSocketMessage{Type: MessageTypeFullSync, Graph: &h.graph}
+
+ case MessageTypeRemoveEdge:
+ if msg.Edge == nil {
+ return nil
+ }
+ // Find and remove the edge
+ found := false
+ newEdges := []Edge{}
+ for _, e := range h.graph.Edges {
+ if (e.From == msg.Edge.From && e.To == msg.Edge.To) ||
+ (e.From == msg.Edge.To && e.To == msg.Edge.From) {
+ found = true
+ continue
+ }
+ newEdges = append(newEdges, e)
+ }
+ if !found {
+ return nil
+ }
+ h.graph.Edges = newEdges
+ log.Printf("Removed edge %d -> %d", msg.Edge.From, msg.Edge.To)
+ response = &WebSocketMessage{Type: MessageTypeRemoveEdge, Edge: msg.Edge}
+
+ case MessageTypeMoveNode:
+ if msg.Node == nil {
+ return nil
+ }
+ if !isValidCoord(msg.Node.X) || !isValidCoord(msg.Node.Y) || !isValidCoord(msg.Node.Z) {
+ return nil
+ }
+ // Find and update the node by ID
+ idx := h.graph.findNodeByID(msg.Node.ID)
+ if idx < 0 {
+ return nil
+ }
+ h.graph.Nodes[idx].X = msg.Node.X
+ h.graph.Nodes[idx].Y = msg.Node.Y
+ h.graph.Nodes[idx].Z = msg.Node.Z
+ movedNode := h.graph.Nodes[idx]
+ response = &WebSocketMessage{Type: MessageTypeMoveNode, Node: &movedNode}
case "reset":
- h.graph = Graph{Nodes: []Node{}, Edges: []Edge{}}
+ h.graph = createInitialGraph()
log.Printf("Graph reset")
- return &WebSocketMessage{Type: MessageTypeFullSync, Graph: &h.graph}
+ response = &WebSocketMessage{Type: MessageTypeFullSync, Graph: &h.graph}
}
- return nil
+ if response == nil {
+ return nil
+ }
+
+ // Marshal while still holding the lock to avoid TOCTOU
+ data, err := json.Marshal(response)
+ if err != nil {
+ log.Printf("Error marshaling response: %v", err)
+ return nil
+ }
+ return data
}
// WebSocket handler
@@ -263,72 +382,76 @@ func serveWebSocket(w http.ResponseWriter, r *http.Request) { log.Printf("WebSocket upgrade error: %v", err)
return
}
-
+
// Create a new connection
conn := &Connection{
ws: ws,
send: make(chan []byte, 256),
}
-
+
// Register the connection with the hub
hub.register <- conn
-
+
// Start the connection handlers
go conn.writer()
conn.reader()
}
-// Writer goroutine for connection
+// Writer goroutine for connection — sends messages and periodic pings
func (c *Connection) writer() {
- // Ensure clean close of connection
+ ticker := time.NewTicker(pingPeriod)
defer func() {
+ ticker.Stop()
c.ws.Close()
}()
-
+
for {
select {
case message, ok := <-c.send:
+ c.ws.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.ws.WriteMessage(websocket.CloseMessage, []byte{})
return
}
-
- // Write the message
if err := c.ws.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
+ case <-ticker.C:
+ c.ws.SetWriteDeadline(time.Now().Add(writeWait))
+ if err := c.ws.WriteMessage(websocket.PingMessage, nil); err != nil {
+ return
+ }
}
}
}
-// Reader goroutine for connection
+// Reader goroutine for connection — reads messages and forwards to hub
func (c *Connection) reader() {
- // Ensure clean close of connection and unregister
defer func() {
hub.unregister <- c
c.ws.Close()
}()
-
- // Set read deadline
- c.ws.SetReadDeadline(time.Now().Add(60 * time.Second))
+
+ // Set message size limit
+ c.ws.SetReadLimit(maxMessageSize)
+ c.ws.SetReadDeadline(time.Now().Add(pongWait))
c.ws.SetPongHandler(func(string) error {
- c.ws.SetReadDeadline(time.Now().Add(60 * time.Second))
+ c.ws.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
-
- // Read messages from the connection
+
for {
_, message, err := c.ws.ReadMessage()
if err != nil {
+ if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
+ log.Printf("WebSocket error: %v", err)
+ }
break
}
-
- // Broadcast the message to all connections
hub.broadcast <- message
}
}
-
func main() {
// Parse command line flags
config := Config{}
@@ -345,15 +468,29 @@ func main() { // Start the hub
go hub.run()
- // Create a file server for static files (serve from parent directory)
- fs := http.FileServer(http.Dir(config.StaticDir))
+ // Create a file server for static files (scoped to prevent directory traversal)
+ staticFS := http.Dir(config.StaticDir)
+ fs := http.FileServer(staticFS)
- // Set up routes
+ // Set up routes — only serve allowed static directories
+ allowedPrefixes := []string{"/css/", "/js/", "/doc/"}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path == "/" {
+ if r.URL.Path == "/" || r.URL.Path == "/index.html" {
http.ServeFile(w, r, config.StaticDir+"/index.html")
return
}
+ // Only serve files from allowed directories
+ allowed := false
+ for _, prefix := range allowedPrefixes {
+ if len(r.URL.Path) >= len(prefix) && r.URL.Path[:len(prefix)] == prefix {
+ allowed = true
+ break
+ }
+ }
+ if !allowed {
+ http.NotFound(w, r)
+ return
+ }
fs.ServeHTTP(w, r)
})
|
