Big commit

This commit is contained in:
2026-03-02 00:13:34 +01:00
parent d146138fca
commit 6d7465ff18
54 changed files with 5999 additions and 2291 deletions

View File

@@ -1,40 +1,25 @@
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 } from 'http';
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 storage for servers and users
interface ServerInfo {
id: string;
name: string;
description?: string;
ownerId: string;
ownerPublicKey: string;
isPrivate: boolean;
maxUsers: number;
currentUsers: number;
tags: string[];
createdAt: number;
lastSeen: number;
}
interface JoinRequest {
id: string;
serverId: string;
userId: string;
userPublicKey: string;
displayName: string;
status: 'pending' | 'approved' | 'rejected';
createdAt: number;
}
// In-memory runtime state (WebSocket connections only not persisted)
interface ConnectedUser {
oderId: string;
ws: WebSocket;
@@ -43,43 +28,38 @@ interface ConnectedUser {
displayName?: string;
}
const servers = new Map<string, ServerInfo>();
const joinRequests = new Map<string, JoinRequest>();
const connectedUsers = new Map<string, ConnectedUser>();
// Persistence
import fs from 'fs';
import path from 'path';
const DATA_DIR = path.join(process.cwd(), 'data');
const SERVERS_FILE = path.join(DATA_DIR, 'servers.json');
const USERS_FILE = path.join(DATA_DIR, 'users.json');
// 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 ensureDataDir() {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
}
function saveServers() {
ensureDataDir();
fs.writeFileSync(SERVERS_FILE, JSON.stringify(Array.from(servers.values()), null, 2));
}
function loadServers() {
ensureDataDir();
if (fs.existsSync(SERVERS_FILE)) {
const raw = fs.readFileSync(SERVERS_FILE, 'utf-8');
const list: ServerInfo[] = JSON.parse(raw);
list.forEach(s => servers.set(s.id, s));
}
}
function hashPassword(pw: string) { return crypto.createHash('sha256').update(pw).digest('hex'); }
// REST API Routes
// Health check endpoint
app.get('/api/health', (req, res) => {
app.get('/api/health', async (req, res) => {
const allServers = await getAllPublicServers();
res.json({
status: 'ok',
timestamp: Date.now(),
serverCount: servers.size,
serverCount: allServers.length,
connectedUsers: connectedUsers.size,
});
});
@@ -129,40 +109,31 @@ app.get('/api/image-proxy', async (req, res) => {
}
});
// Basic auth (demo - file-based)
interface AuthUser { id: string; username: string; passwordHash: string; displayName: string; createdAt: number; }
let authUsers: AuthUser[] = [];
function saveUsers() { ensureDataDir(); fs.writeFileSync(USERS_FILE, JSON.stringify(authUsers, null, 2)); }
function loadUsers() { ensureDataDir(); if (fs.existsSync(USERS_FILE)) { authUsers = JSON.parse(fs.readFileSync(USERS_FILE,'utf-8')); } }
import crypto from 'crypto';
import { initDB, getUserByUsername, createUser } from './db';
function hashPassword(pw: string) { return crypto.createHash('sha256').update(pw).digest('hex'); }
// 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' });
await initDB();
const exists = await getUserByUsername(username);
if (exists) return res.status(409).json({ error: 'Username taken' });
const user: AuthUser = { id: uuidv4(), username, passwordHash: hashPassword(password), displayName: displayName || username, createdAt: Date.now() };
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;
await initDB();
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', (req, res) => {
app.get('/api/servers', async (req, res) => {
const { q, tags, limit = 20, offset = 0 } = req.query;
let results = Array.from(servers.values())
.filter(s => !s.isPrivate)
let results = await getAllPublicServers();
results = results
.filter(s => {
if (q) {
const query = String(q).toLowerCase();
@@ -179,8 +150,6 @@ app.get('/api/servers', (req, res) => {
return true;
});
// Keep servers visible permanently until deleted; do not filter by lastSeen
const total = results.length;
results = results.slice(Number(offset), Number(offset) + Number(limit));
@@ -188,14 +157,13 @@ app.get('/api/servers', (req, res) => {
});
// Register a server
app.post('/api/servers', (req, res) => {
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' });
}
// Use client-provided ID if available, otherwise generate one
const id = clientId || uuidv4();
const server: ServerInfo = {
id,
@@ -211,17 +179,16 @@ app.post('/api/servers', (req, res) => {
lastSeen: Date.now(),
};
servers.set(id, server);
saveServers();
await upsertServer(server);
res.status(201).json(server);
});
// Update server
app.put('/api/servers/:id', (req, res) => {
app.put('/api/servers/:id', async (req, res) => {
const { id } = req.params;
const { ownerId, ...updates } = req.body;
const server = servers.get(id);
const server = await getServerById(id);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
@@ -230,18 +197,17 @@ app.put('/api/servers/:id', (req, res) => {
return res.status(403).json({ error: 'Not authorized' });
}
const updated = { ...server, ...updates, lastSeen: Date.now() };
servers.set(id, updated);
saveServers();
const updated: ServerInfo = { ...server, ...updates, lastSeen: Date.now() };
await upsertServer(updated);
res.json(updated);
});
// Heartbeat - keep server alive
app.post('/api/servers/:id/heartbeat', (req, res) => {
app.post('/api/servers/:id/heartbeat', async (req, res) => {
const { id } = req.params;
const { currentUsers } = req.body;
const server = servers.get(id);
const server = await getServerById(id);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
@@ -250,18 +216,17 @@ app.post('/api/servers/:id/heartbeat', (req, res) => {
if (typeof currentUsers === 'number') {
server.currentUsers = currentUsers;
}
servers.set(id, server);
saveServers();
await upsertServer(server);
res.json({ ok: true });
});
// Remove server
app.delete('/api/servers/:id', (req, res) => {
app.delete('/api/servers/:id', async (req, res) => {
const { id } = req.params;
const { ownerId } = req.body;
const server = servers.get(id);
const server = await getServerById(id);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
@@ -270,17 +235,16 @@ app.delete('/api/servers/:id', (req, res) => {
return res.status(403).json({ error: 'Not authorized' });
}
servers.delete(id);
saveServers();
await dbDeleteServer(id);
res.json({ ok: true });
});
// Request to join a server
app.post('/api/servers/:id/join', (req, res) => {
app.post('/api/servers/:id/join', async (req, res) => {
const { id: serverId } = req.params;
const { userId, userPublicKey, displayName } = req.body;
const server = servers.get(serverId);
const server = await getServerById(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
@@ -296,7 +260,7 @@ app.post('/api/servers/:id/join', (req, res) => {
createdAt: Date.now(),
};
joinRequests.set(requestId, request);
await createJoinRequest(request);
// Notify server owner via WebSocket
if (server.isPrivate) {
@@ -310,11 +274,11 @@ app.post('/api/servers/:id/join', (req, res) => {
});
// Get join requests for a server
app.get('/api/servers/:id/requests', (req, res) => {
app.get('/api/servers/:id/requests', async (req, res) => {
const { id: serverId } = req.params;
const { ownerId } = req.query;
const server = servers.get(serverId);
const server = await getServerById(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
@@ -323,41 +287,58 @@ app.get('/api/servers/:id/requests', (req, res) => {
return res.status(403).json({ error: 'Not authorized' });
}
const requests = Array.from(joinRequests.values())
.filter(r => r.serverId === serverId && r.status === 'pending');
const requests = await getPendingRequestsForServer(serverId);
res.json({ requests });
});
// Approve/reject join request
app.put('/api/requests/:id', (req, res) => {
app.put('/api/requests/:id', async (req, res) => {
const { id } = req.params;
const { ownerId, status } = req.body;
const request = joinRequests.get(id);
const request = await getJoinRequestById(id);
if (!request) {
return res.status(404).json({ error: 'Request not found' });
}
const server = servers.get(request.serverId);
const server = await getServerById(request.serverId);
if (!server || server.ownerId !== ownerId) {
return res.status(403).json({ error: 'Not authorized' });
}
request.status = status;
joinRequests.set(id, request);
await updateJoinRequestStatus(id, status);
const updated = { ...request, status };
// Notify the requester
notifyUser(request.userId, {
type: 'request_update',
request,
request: updated,
});
res.json(request);
res.json(updated);
});
// WebSocket Server for real-time signaling
const server = createServer(app);
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) => {
@@ -559,24 +540,20 @@ function findUserByUserId(oderId: string): ConnectedUser | undefined {
return Array.from(connectedUsers.values()).find(u => u.oderId === oderId);
}
// Cleanup old data periodically
// Simple cleanup only for stale join requests (keep servers permanent)
// Cleanup stale join requests periodically (older than 24 h)
setInterval(() => {
const now = Date.now();
joinRequests.forEach((request, id) => {
if (now - request.createdAt > 24 * 60 * 60 * 1000) {
joinRequests.delete(id);
}
});
deleteStaleJoinRequests(24 * 60 * 60 * 1000).catch(err =>
console.error('Failed to clean up stale join requests:', err),
);
}, 60 * 1000);
initDB().then(() => {
server.listen(PORT, () => {
console.log(`🚀 MetoYou signaling server running on port ${PORT}`);
console.log(` REST API: http://localhost:${PORT}/api`);
console.log(` WebSocket: ws://localhost:${PORT}`);
// Load servers on startup
loadServers();
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);