import dotenv from 'dotenv'; import path from 'path'; import fs from 'fs'; // Load .env from project root (one level up from server/) dotenv.config({ path: path.resolve(__dirname, '..', '..', '.env') }); import express from 'express'; import cors from 'cors'; import { createServer as createHttpServer } from 'http'; import { createServer as createHttpsServer } from 'https'; import { WebSocketServer, WebSocket } from 'ws'; import { v4 as uuidv4 } from 'uuid'; const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true'; const app = express(); const PORT = process.env.PORT || 3001; app.use(cors()); app.use(express.json()); // In-memory runtime state (WebSocket connections only – not persisted) interface ConnectedUser { oderId: string; ws: WebSocket; serverIds: Set; // all servers the user is a member of viewedServerId?: string; // currently viewed/active server displayName?: string; } const connectedUsers = new Map(); // Database import crypto from 'crypto'; import { initDB, getUserByUsername, createUser, getAllPublicServers, getServerById, upsertServer, deleteServer as dbDeleteServer, createJoinRequest, getJoinRequestById, getPendingRequestsForServer, updateJoinRequestStatus, deleteStaleJoinRequests, ServerInfo, JoinRequest, } from './db'; function hashPassword(pw: string) { return crypto.createHash('sha256').update(pw).digest('hex'); } // REST API Routes // Health check endpoint app.get('/api/health', async (req, res) => { const allServers = await getAllPublicServers(); res.json({ status: 'ok', timestamp: Date.now(), serverCount: allServers.length, connectedUsers: connectedUsers.size, }); }); // Time endpoint for clock synchronization app.get('/api/time', (req, res) => { res.json({ now: Date.now() }); }); // Image proxy to allow rendering external images within CSP (img-src 'self' data: blob:) app.get('/api/image-proxy', async (req, res) => { try { const url = String(req.query.url || ''); if (!/^https?:\/\//i.test(url)) { return res.status(400).json({ error: 'Invalid URL' }); } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); const response = await fetch(url, { redirect: 'follow', signal: controller.signal }); clearTimeout(timeout); if (!response.ok) { return res.status(response.status).end(); } const contentType = response.headers.get('content-type') || ''; if (!contentType.toLowerCase().startsWith('image/')) { return res.status(415).json({ error: 'Unsupported content type' }); } const arrayBuffer = await response.arrayBuffer(); const MAX_BYTES = 8 * 1024 * 1024; // 8MB limit if (arrayBuffer.byteLength > MAX_BYTES) { return res.status(413).json({ error: 'Image too large' }); } res.setHeader('Content-Type', contentType); res.setHeader('Cache-Control', 'public, max-age=3600'); res.send(Buffer.from(arrayBuffer)); } catch (err) { if ((err as any)?.name === 'AbortError') { return res.status(504).json({ error: 'Timeout fetching image' }); } console.error('Image proxy error:', err); res.status(502).json({ error: 'Failed to fetch image' }); } }); // Auth app.post('/api/users/register', async (req, res) => { const { username, password, displayName } = req.body; if (!username || !password) return res.status(400).json({ error: 'Missing username/password' }); const exists = await getUserByUsername(username); if (exists) return res.status(409).json({ error: 'Username taken' }); const user = { id: uuidv4(), username, passwordHash: hashPassword(password), displayName: displayName || username, createdAt: Date.now() }; await createUser(user); res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName }); }); app.post('/api/users/login', async (req, res) => { const { username, password } = req.body; const user = await getUserByUsername(username); if (!user || user.passwordHash !== hashPassword(password)) return res.status(401).json({ error: 'Invalid credentials' }); res.json({ id: user.id, username: user.username, displayName: user.displayName }); }); // Search servers app.get('/api/servers', async (req, res) => { const { q, tags, limit = 20, offset = 0 } = req.query; let results = await getAllPublicServers(); results = results .filter(s => { if (q) { const query = String(q).toLowerCase(); return s.name.toLowerCase().includes(query) || s.description?.toLowerCase().includes(query); } return true; }) .filter(s => { if (tags) { const tagList = String(tags).split(','); return tagList.some(t => s.tags.includes(t)); } return true; }); const total = results.length; results = results.slice(Number(offset), Number(offset) + Number(limit)); res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) }); }); // Register a server app.post('/api/servers', async (req, res) => { const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body; if (!name || !ownerId || !ownerPublicKey) { return res.status(400).json({ error: 'Missing required fields' }); } const id = clientId || uuidv4(); const server: ServerInfo = { id, name, description, ownerId, ownerPublicKey, isPrivate: isPrivate ?? false, maxUsers: maxUsers ?? 0, currentUsers: 0, tags: tags ?? [], createdAt: Date.now(), lastSeen: Date.now(), }; await upsertServer(server); res.status(201).json(server); }); // Update server app.put('/api/servers/:id', async (req, res) => { const { id } = req.params; const { ownerId, ...updates } = req.body; const server = await getServerById(id); if (!server) { return res.status(404).json({ error: 'Server not found' }); } if (server.ownerId !== ownerId) { return res.status(403).json({ error: 'Not authorized' }); } const updated: ServerInfo = { ...server, ...updates, lastSeen: Date.now() }; await upsertServer(updated); res.json(updated); }); // Heartbeat - keep server alive app.post('/api/servers/:id/heartbeat', async (req, res) => { const { id } = req.params; const { currentUsers } = req.body; const server = await getServerById(id); if (!server) { return res.status(404).json({ error: 'Server not found' }); } server.lastSeen = Date.now(); if (typeof currentUsers === 'number') { server.currentUsers = currentUsers; } await upsertServer(server); res.json({ ok: true }); }); // Remove server app.delete('/api/servers/:id', async (req, res) => { const { id } = req.params; const { ownerId } = req.body; const server = await getServerById(id); if (!server) { return res.status(404).json({ error: 'Server not found' }); } if (server.ownerId !== ownerId) { return res.status(403).json({ error: 'Not authorized' }); } await dbDeleteServer(id); res.json({ ok: true }); }); // Request to join a server app.post('/api/servers/:id/join', async (req, res) => { const { id: serverId } = req.params; const { userId, userPublicKey, displayName } = req.body; const server = await getServerById(serverId); if (!server) { return res.status(404).json({ error: 'Server not found' }); } const requestId = uuidv4(); const request: JoinRequest = { id: requestId, serverId, userId, userPublicKey, displayName, status: server.isPrivate ? 'pending' : 'approved', createdAt: Date.now(), }; await createJoinRequest(request); // Notify server owner via WebSocket if (server.isPrivate) { notifyServerOwner(server.ownerId, { type: 'join_request', request, }); } res.status(201).json(request); }); // Get join requests for a server app.get('/api/servers/:id/requests', async (req, res) => { const { id: serverId } = req.params; const { ownerId } = req.query; const server = await getServerById(serverId); if (!server) { return res.status(404).json({ error: 'Server not found' }); } if (server.ownerId !== ownerId) { return res.status(403).json({ error: 'Not authorized' }); } const requests = await getPendingRequestsForServer(serverId); res.json({ requests }); }); // Approve/reject join request app.put('/api/requests/:id', async (req, res) => { const { id } = req.params; const { ownerId, status } = req.body; const request = await getJoinRequestById(id); if (!request) { return res.status(404).json({ error: 'Request not found' }); } const server = await getServerById(request.serverId); if (!server || server.ownerId !== ownerId) { return res.status(403).json({ error: 'Not authorized' }); } await updateJoinRequestStatus(id, status); const updated = { ...request, status }; // Notify the requester notifyUser(request.userId, { type: 'request_update', request: updated, }); res.json(updated); }); // WebSocket Server for real-time signaling function buildServer() { if (USE_SSL) { // Look for certs relative to project root (one level up from server/) const certDir = path.resolve(__dirname, '..', '..', '.certs'); const certFile = path.join(certDir, 'localhost.crt'); const keyFile = path.join(certDir, 'localhost.key'); if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) { console.error(`SSL=true but certs not found in ${certDir}`); console.error('Run ./generate-cert.sh first.'); process.exit(1); } return createHttpsServer( { cert: fs.readFileSync(certFile), key: fs.readFileSync(keyFile) }, app, ); } return createHttpServer(app); } const server = buildServer(); const wss = new WebSocketServer({ server }); wss.on('connection', (ws: WebSocket) => { const connectionId = uuidv4(); connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() }); ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); handleWebSocketMessage(connectionId, message); } catch (err) { console.error('Invalid WebSocket message:', err); } }); ws.on('close', () => { const user = connectedUsers.get(connectionId); if (user) { // Notify all servers the user was a member of user.serverIds.forEach((sid) => { broadcastToServer(sid, { type: 'user_left', oderId: user.oderId, displayName: user.displayName, serverId: sid, }, user.oderId); }); } connectedUsers.delete(connectionId); }); // Send connection acknowledgment with the connectionId (client will identify with their actual oderId) ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() })); }); function handleWebSocketMessage(connectionId: string, message: any): void { const user = connectedUsers.get(connectionId); if (!user) return; switch (message.type) { case 'identify': // User identifies themselves with their permanent ID // Store their actual oderId for peer-to-peer routing user.oderId = message.oderId || connectionId; user.displayName = message.displayName || 'Anonymous'; connectedUsers.set(connectionId, user); console.log(`User identified: ${user.displayName} (${user.oderId})`); break; case 'join_server': { const sid = message.serverId; const isNew = !user.serverIds.has(sid); user.serverIds.add(sid); user.viewedServerId = sid; connectedUsers.set(connectionId, user); console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`); // Always send the current user list for this server const usersInServer = Array.from(connectedUsers.values()) .filter(u => u.serverIds.has(sid) && u.oderId !== user.oderId && u.displayName) .map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' })); console.log(`Sending server_users to ${user.displayName || 'Anonymous'}:`, usersInServer); user.ws.send(JSON.stringify({ type: 'server_users', serverId: sid, users: usersInServer, })); // Only broadcast user_joined if this is a brand-new join (not a re-view) if (isNew) { broadcastToServer(sid, { type: 'user_joined', oderId: user.oderId, displayName: user.displayName || 'Anonymous', serverId: sid, }, user.oderId); } break; } case 'view_server': { // Just switch the viewed server without joining/leaving const viewSid = message.serverId; user.viewedServerId = viewSid; connectedUsers.set(connectionId, user); console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`); // Send current user list for the viewed server const viewUsers = Array.from(connectedUsers.values()) .filter(u => u.serverIds.has(viewSid) && u.oderId !== user.oderId && u.displayName) .map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' })); user.ws.send(JSON.stringify({ type: 'server_users', serverId: viewSid, users: viewUsers, })); break; } case 'leave_server': { const leaveSid = message.serverId || user.viewedServerId; if (leaveSid) { user.serverIds.delete(leaveSid); if (user.viewedServerId === leaveSid) { user.viewedServerId = undefined; } connectedUsers.set(connectionId, user); broadcastToServer(leaveSid, { type: 'user_left', oderId: user.oderId, displayName: user.displayName || 'Anonymous', serverId: leaveSid, }, user.oderId); } break; } case 'offer': case 'answer': case 'ice_candidate': // Forward signaling messages to specific peer console.log(`Forwarding ${message.type} from ${user.oderId} to ${message.targetUserId}`); const targetUser = findUserByUserId(message.targetUserId); if (targetUser) { targetUser.ws.send(JSON.stringify({ ...message, fromUserId: user.oderId, })); console.log(`Successfully forwarded ${message.type} to ${message.targetUserId}`); } else { console.log(`Target user ${message.targetUserId} not found. Connected users:`, Array.from(connectedUsers.values()).map(u => ({ oderId: u.oderId, displayName: u.displayName }))); } break; case 'chat_message': { // Broadcast chat message to all users in the server const chatSid = message.serverId || user.viewedServerId; if (chatSid && user.serverIds.has(chatSid)) { broadcastToServer(chatSid, { type: 'chat_message', serverId: chatSid, message: message.message, senderId: user.oderId, senderName: user.displayName, timestamp: Date.now(), }); } break; } case 'typing': { // Broadcast typing indicator const typingSid = message.serverId || user.viewedServerId; if (typingSid && user.serverIds.has(typingSid)) { broadcastToServer(typingSid, { type: 'user_typing', serverId: typingSid, oderId: user.oderId, displayName: user.displayName, }, user.oderId); } break; } default: console.log('Unknown message type:', message.type); } } function broadcastToServer(serverId: string, message: any, excludeOderId?: string): void { console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type); connectedUsers.forEach((user) => { if (user.serverIds.has(serverId) && user.oderId !== excludeOderId) { console.log(` -> Sending to ${user.displayName} (${user.oderId})`); user.ws.send(JSON.stringify(message)); } }); } function notifyServerOwner(ownerId: string, message: any): void { const owner = findUserByUserId(ownerId); if (owner) { owner.ws.send(JSON.stringify(message)); } } function notifyUser(oderId: string, message: any): void { const user = findUserByUserId(oderId); if (user) { user.ws.send(JSON.stringify(message)); } } function findUserByUserId(oderId: string): ConnectedUser | undefined { return Array.from(connectedUsers.values()).find(u => u.oderId === oderId); } // Cleanup stale join requests periodically (older than 24 h) setInterval(() => { deleteStaleJoinRequests(24 * 60 * 60 * 1000).catch(err => console.error('Failed to clean up stale join requests:', err), ); }, 60 * 1000); initDB().then(() => { server.listen(PORT, () => { const proto = USE_SSL ? 'https' : 'http'; const wsProto = USE_SSL ? 'wss' : 'ws'; console.log(`🚀 MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`); console.log(` REST API: ${proto}://localhost:${PORT}/api`); console.log(` WebSocket: ${wsProto}://localhost:${PORT}`); }); }).catch((err) => { console.error('Failed to initialize database:', err); process.exit(1); });