This commit is contained in:
2025-12-28 05:37:19 +01:00
commit 87c722b5ae
74 changed files with 10264 additions and 0 deletions

102
server/src/db.ts Normal file
View File

@@ -0,0 +1,102 @@
import fs from 'fs';
import path from 'path';
import initSqlJs, { Database, Statement } from 'sql.js';
// Simple SQLite via sql.js persisted to a single file
const DATA_DIR = path.join(process.cwd(), 'data');
const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite');
function ensureDataDir() {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
}
let SQL: any = null;
let db: Database | null = null;
export async function initDB(): Promise<void> {
if (db) return;
SQL = await initSqlJs({ locateFile: (file: string) => require.resolve('sql.js/dist/sql-wasm.wasm') });
ensureDataDir();
if (fs.existsSync(DB_FILE)) {
const fileBuffer = fs.readFileSync(DB_FILE);
db = new SQL.Database(new Uint8Array(fileBuffer));
} else {
db = new SQL.Database();
}
// Initialize schema
db.run(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
passwordHash TEXT NOT NULL,
displayName TEXT NOT NULL,
createdAt INTEGER NOT NULL
);
`);
persist();
}
function persist(): void {
if (!db) return;
const data = db.export();
const buffer = Buffer.from(data);
fs.writeFileSync(DB_FILE, buffer);
}
export interface AuthUser {
id: string;
username: string;
passwordHash: string;
displayName: string;
createdAt: number;
}
export async function getUserByUsername(username: string): Promise<AuthUser | null> {
if (!db) await initDB();
const stmt: Statement = db!.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE username = ? LIMIT 1');
stmt.bind([username]);
let row: AuthUser | null = null;
if (stmt.step()) {
const r = stmt.getAsObject() as any;
row = {
id: String(r.id),
username: String(r.username),
passwordHash: String(r.passwordHash),
displayName: String(r.displayName),
createdAt: Number(r.createdAt),
};
}
stmt.free();
return row;
}
export async function getUserById(id: string): Promise<AuthUser | null> {
if (!db) await initDB();
const stmt: Statement = db!.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE id = ? LIMIT 1');
stmt.bind([id]);
let row: AuthUser | null = null;
if (stmt.step()) {
const r = stmt.getAsObject() as any;
row = {
id: String(r.id),
username: String(r.username),
passwordHash: String(r.passwordHash),
displayName: String(r.displayName),
createdAt: Number(r.createdAt),
};
}
stmt.free();
return row;
}
export async function createUser(user: AuthUser): Promise<void> {
if (!db) await initDB();
const stmt = db!.prepare('INSERT INTO users (id, username, passwordHash, displayName, createdAt) VALUES (?, ?, ?, ?, ?)');
stmt.bind([user.id, user.username, user.passwordHash, user.displayName, user.createdAt]);
stmt.step();
stmt.free();
persist();
}

501
server/src/index.ts Normal file
View File

@@ -0,0 +1,501 @@
import express from 'express';
import cors from 'cors';
import { createServer } from 'http';
import { WebSocketServer, WebSocket } from 'ws';
import { v4 as uuidv4 } from 'uuid';
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;
}
interface ConnectedUser {
oderId: string;
ws: WebSocket;
serverId?: string;
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');
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));
}
}
// REST API Routes
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: Date.now(),
serverCount: servers.size,
connectedUsers: connectedUsers.size,
});
});
// Time endpoint for clock synchronization
app.get('/api/time', (req, res) => {
res.json({ now: Date.now() });
});
// 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'); }
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() };
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) => {
const { q, tags, limit = 20, offset = 0 } = req.query;
let results = Array.from(servers.values())
.filter(s => !s.isPrivate)
.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;
});
// Keep servers visible permanently until deleted; do not filter by lastSeen
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', (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,
name,
description,
ownerId,
ownerPublicKey,
isPrivate: isPrivate ?? false,
maxUsers: maxUsers ?? 0,
currentUsers: 0,
tags: tags ?? [],
createdAt: Date.now(),
lastSeen: Date.now(),
};
servers.set(id, server);
saveServers();
res.status(201).json(server);
});
// Update server
app.put('/api/servers/:id', (req, res) => {
const { id } = req.params;
const { ownerId, ...updates } = req.body;
const server = servers.get(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 = { ...server, ...updates, lastSeen: Date.now() };
servers.set(id, updated);
saveServers();
res.json(updated);
});
// Heartbeat - keep server alive
app.post('/api/servers/:id/heartbeat', (req, res) => {
const { id } = req.params;
const { currentUsers } = req.body;
const server = servers.get(id);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
server.lastSeen = Date.now();
if (typeof currentUsers === 'number') {
server.currentUsers = currentUsers;
}
servers.set(id, server);
saveServers();
res.json({ ok: true });
});
// Remove server
app.delete('/api/servers/:id', (req, res) => {
const { id } = req.params;
const { ownerId } = req.body;
const server = servers.get(id);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
if (server.ownerId !== ownerId) {
return res.status(403).json({ error: 'Not authorized' });
}
servers.delete(id);
saveServers();
res.json({ ok: true });
});
// Request to join a server
app.post('/api/servers/:id/join', (req, res) => {
const { id: serverId } = req.params;
const { userId, userPublicKey, displayName } = req.body;
const server = servers.get(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(),
};
joinRequests.set(requestId, 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', (req, res) => {
const { id: serverId } = req.params;
const { ownerId } = req.query;
const server = servers.get(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 = Array.from(joinRequests.values())
.filter(r => r.serverId === serverId && r.status === 'pending');
res.json({ requests });
});
// Approve/reject join request
app.put('/api/requests/:id', (req, res) => {
const { id } = req.params;
const { ownerId, status } = req.body;
const request = joinRequests.get(id);
if (!request) {
return res.status(404).json({ error: 'Request not found' });
}
const server = servers.get(request.serverId);
if (!server || server.ownerId !== ownerId) {
return res.status(403).json({ error: 'Not authorized' });
}
request.status = status;
joinRequests.set(id, request);
// Notify the requester
notifyUser(request.userId, {
type: 'request_update',
request,
});
res.json(request);
});
// WebSocket Server for real-time signaling
const server = createServer(app);
const wss = new WebSocketServer({ server });
wss.on('connection', (ws: WebSocket) => {
const oderId = uuidv4();
connectedUsers.set(oderId, { oderId, ws });
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
handleWebSocketMessage(oderId, message);
} catch (err) {
console.error('Invalid WebSocket message:', err);
}
});
ws.on('close', () => {
const user = connectedUsers.get(oderId);
if (user?.serverId) {
// Notify others in the room
broadcastToServer(user.serverId, {
type: 'user_left',
oderId,
displayName: user.displayName,
}, oderId);
}
connectedUsers.delete(oderId);
});
// Send connection acknowledgment
ws.send(JSON.stringify({ type: 'connected', oderId, 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':
user.serverId = message.serverId;
connectedUsers.set(connectionId, user);
console.log(`User ${user.displayName} (${user.oderId}) joined server ${message.serverId}`);
// Get list of current users in server (exclude this user by oderId)
const usersInServer = Array.from(connectedUsers.values())
.filter(u => u.serverId === message.serverId && u.oderId !== user.oderId)
.map(u => ({ oderId: u.oderId, displayName: u.displayName }));
console.log(`Sending server_users to ${user.displayName}:`, usersInServer);
user.ws.send(JSON.stringify({
type: 'server_users',
users: usersInServer,
}));
// Notify others (exclude by oderId, not connectionId)
broadcastToServer(message.serverId, {
type: 'user_joined',
oderId: user.oderId,
displayName: user.displayName,
}, user.oderId);
break;
case 'leave_server':
const oldServerId = user.serverId;
user.serverId = undefined;
connectedUsers.set(connectionId, user);
if (oldServerId) {
broadcastToServer(oldServerId, {
type: 'user_left',
oderId: user.oderId,
displayName: user.displayName,
}, 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
if (user.serverId) {
broadcastToServer(user.serverId, {
type: 'chat_message',
message: message.message,
senderId: user.oderId,
senderName: user.displayName,
timestamp: Date.now(),
});
}
break;
case 'typing':
// Broadcast typing indicator
if (user.serverId) {
broadcastToServer(user.serverId, {
type: 'user_typing',
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.serverId === 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 old data periodically
// Simple cleanup only for stale join requests (keep servers permanent)
setInterval(() => {
const now = Date.now();
joinRequests.forEach((request, id) => {
if (now - request.createdAt > 24 * 60 * 60 * 1000) {
joinRequests.delete(id);
}
});
}, 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();
});
}).catch((err) => {
console.error('Failed to initialize database:', err);
process.exit(1);
});

5
server/src/types/sqljs.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module 'sql.js' {
export default function initSqlJs(config?: { locateFile?: (file: string) => string }): Promise<any>;
export type Database = any;
export type Statement = any;
}