Refactor 4 with bugfixes

This commit is contained in:
2026-03-04 03:56:23 +01:00
parent be91b6dfe8
commit 0ed9ca93d3
51 changed files with 1552 additions and 996 deletions

View File

@@ -1,356 +1,23 @@
import 'reflect-metadata';
import dotenv from 'dotenv';
import path from 'path';
import fs from 'fs';
import { createServer as createHttpServer } from 'http';
import { createServer as createHttpsServer } from 'https';
// 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';
import { initDatabase } from './db';
import { deleteStaleJoinRequests } from './cqrs';
import { createApp } from './app';
import { setupWebSocket } from './websocket';
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<string>; // all servers the user is a member of
viewedServerId?: string; // currently viewed/active server
displayName?: string;
}
const connectedUsers = new Map<string, ConnectedUser>();
// 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() {
function buildServer(app: ReturnType<typeof createApp>) {
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');
@@ -370,249 +37,31 @@ function buildServer() {
return createHttpServer(app);
}
const server = buildServer();
const wss = new WebSocketServer({ server });
async function bootstrap(): Promise<void> {
await initDatabase();
wss.on('connection', (ws: WebSocket) => {
const connectionId = uuidv4();
const app = createApp();
const server = buildServer(app);
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() });
setupWebSocket(server);
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
// Periodically clean up stale join requests (older than 24 h)
setInterval(() => {
deleteStaleJoinRequests(24 * 60 * 60 * 1000)
.catch(err => console.error('Failed to clean up stale join requests:', err));
}, 60 * 1000);
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(`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);
});
}
bootstrap().catch((err) => {
console.error('Failed to start server:', err);
process.exit(1);
});