init
This commit is contained in:
27
server/data/servers.json
Normal file
27
server/data/servers.json
Normal 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
9
server/data/users.json
Normal 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
2
server/dist/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
1
server/dist/index.d.ts.map
vendored
Normal file
1
server/dist/index.d.ts.map
vendored
Normal 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
395
server/dist/index.js
vendored
Normal 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
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
27
server/package.json
Normal 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
102
server/src/db.ts
Normal 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
501
server/src/index.ts
Normal 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
5
server/src/types/sqljs.d.ts
vendored
Normal 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
19
server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user