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

27
server/data/servers.json Normal file
View File

@@ -0,0 +1,27 @@
[
{
"id": "5b9ee424-dcfc-4bcb-a0cd-175b0d4dc3d7",
"name": "hello",
"ownerId": "7c74c680-09ca-42ee-b5fb-650e8eaa1622",
"ownerPublicKey": "5b870756-bdfd-47c0-9a27-90f5838b66ac",
"isPrivate": false,
"maxUsers": 50,
"currentUsers": 0,
"tags": [],
"createdAt": 1766898986953,
"lastSeen": 1766898986953
},
{
"id": "39071c2e-6715-45a7-ac56-9e82ec4fae03",
"name": "HeePassword",
"description": "ME ME",
"ownerId": "53b1172a-acff-4e19-9773-a2a23408b3c0",
"ownerPublicKey": "53b1172a-acff-4e19-9773-a2a23408b3c0",
"isPrivate": true,
"maxUsers": 50,
"currentUsers": 0,
"tags": [],
"createdAt": 1766902260144,
"lastSeen": 1766902260144
}
]

9
server/data/users.json Normal file
View File

@@ -0,0 +1,9 @@
[
{
"id": "54c0953a-1e54-4c07-8da9-06c143d9354f",
"username": "azaaxin",
"passwordHash": "9f3a38af5ddad28abc9a273e2481883b245e5a908266c2ce1f0e42c7fa175d6c",
"displayName": "azaaxin",
"createdAt": 1766902824975
}
]

2
server/dist/index.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=index.d.ts.map

1
server/dist/index.d.ts.map vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}

395
server/dist/index.js vendored Normal file
View File

@@ -0,0 +1,395 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const http_1 = require("http");
const ws_1 = require("ws");
const uuid_1 = require("uuid");
const app = (0, express_1.default)();
const PORT = process.env.PORT || 3001;
app.use((0, cors_1.default)());
app.use(express_1.default.json());
const servers = new Map();
const joinRequests = new Map();
const connectedUsers = new Map();
// Persistence
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const DATA_DIR = path_1.default.join(process.cwd(), 'data');
const SERVERS_FILE = path_1.default.join(DATA_DIR, 'servers.json');
const USERS_FILE = path_1.default.join(DATA_DIR, 'users.json');
function ensureDataDir() {
if (!fs_1.default.existsSync(DATA_DIR))
fs_1.default.mkdirSync(DATA_DIR, { recursive: true });
}
function saveServers() {
ensureDataDir();
fs_1.default.writeFileSync(SERVERS_FILE, JSON.stringify(Array.from(servers.values()), null, 2));
}
function loadServers() {
ensureDataDir();
if (fs_1.default.existsSync(SERVERS_FILE)) {
const raw = fs_1.default.readFileSync(SERVERS_FILE, 'utf-8');
const list = 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,
});
});
let authUsers = [];
function saveUsers() { ensureDataDir(); fs_1.default.writeFileSync(USERS_FILE, JSON.stringify(authUsers, null, 2)); }
function loadUsers() { ensureDataDir(); if (fs_1.default.existsSync(USERS_FILE)) {
authUsers = JSON.parse(fs_1.default.readFileSync(USERS_FILE, 'utf-8'));
} }
const crypto_1 = __importDefault(require("crypto"));
function hashPassword(pw) { return crypto_1.default.createHash('sha256').update(pw).digest('hex'); }
app.post('/api/users/register', (req, res) => {
const { username, password, displayName } = req.body;
if (!username || !password)
return res.status(400).json({ error: 'Missing username/password' });
if (authUsers.find(u => u.username === username))
return res.status(409).json({ error: 'Username taken' });
const user = { id: (0, uuid_1.v4)(), username, passwordHash: hashPassword(password), displayName: displayName || username, createdAt: Date.now() };
authUsers.push(user);
saveUsers();
res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName });
});
app.post('/api/users/login', (req, res) => {
const { username, password } = req.body;
const user = authUsers.find(u => u.username === username && u.passwordHash === hashPassword(password));
if (!user)
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 || (0, uuid_1.v4)();
const server = {
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 = (0, uuid_1.v4)();
const request = {
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 = (0, http_1.createServer)(app);
const wss = new ws_1.WebSocketServer({ server });
wss.on('connection', (ws) => {
const oderId = (0, uuid_1.v4)();
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 }));
});
function handleWebSocketMessage(connectionId, message) {
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, message, excludeOderId) {
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, message) {
const owner = findUserByUserId(ownerId);
if (owner) {
owner.ws.send(JSON.stringify(message));
}
}
function notifyUser(oderId, message) {
const user = findUserByUserId(oderId);
if (user) {
user.ws.send(JSON.stringify(message));
}
}
function findUserByUserId(oderId) {
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);
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();
});
//# sourceMappingURL=index.js.map

1
server/dist/index.js.map vendored Normal file

File diff suppressed because one or more lines are too long

27
server/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "metoyou-server",
"version": "1.0.0",
"description": "Signaling server for MetoYou P2P chat application",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node-dev --respawn src/index.ts"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"sql.js": "^1.9.0",
"uuid": "^9.0.0",
"ws": "^8.14.2"
},
"devDependencies": {
"@types/cors": "^2.8.14",
"@types/express": "^4.17.18",
"@types/node": "^20.8.0",
"@types/uuid": "^9.0.4",
"@types/ws": "^8.5.8",
"ts-node-dev": "^2.0.0",
"typescript": "^5.2.2"
}
}

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;
}

19
server/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}