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

Binary file not shown.

View File

@@ -1,39 +1,14 @@
[
{
"id": "5b9ee424-dcfc-4bcb-a0cd-175b0d4dc3d7",
"name": "hello",
"ownerId": "7c74c680-09ca-42ee-b5fb-650e8eaa1622",
"ownerPublicKey": "5b870756-bdfd-47c0-9a27-90f5838b66ac",
"id": "274b8cec-83cf-41b6-981f-f5116c90696e",
"name": "Opem",
"ownerId": "a01f9b26-b443-49a7-82a1-4d75c9bc9824",
"ownerPublicKey": "a01f9b26-b443-49a7-82a1-4d75c9bc9824",
"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
},
{
"id": "337ad599-736e-49c6-bf01-fb94c1b82a6d",
"name": "ASDASD",
"ownerId": "54c0953a-1e54-4c07-8da9-06c143d9354f",
"ownerPublicKey": "54c0953a-1e54-4c07-8da9-06c143d9354f",
"isPrivate": false,
"maxUsers": 50,
"currentUsers": 0,
"tags": [],
"createdAt": 1767240654523,
"lastSeen": 1767240654523
"createdAt": 1772382716566,
"lastSeen": 1772382716566
}
]

View File

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

43
server/dist/db.d.ts vendored Normal file
View File

@@ -0,0 +1,43 @@
export declare function initDB(): Promise<void>;
export interface AuthUser {
id: string;
username: string;
passwordHash: string;
displayName: string;
createdAt: number;
}
export declare function getUserByUsername(username: string): Promise<AuthUser | null>;
export declare function getUserById(id: string): Promise<AuthUser | null>;
export declare function createUser(user: AuthUser): Promise<void>;
export interface ServerInfo {
id: string;
name: string;
description?: string;
ownerId: string;
ownerPublicKey: string;
isPrivate: boolean;
maxUsers: number;
currentUsers: number;
tags: string[];
createdAt: number;
lastSeen: number;
}
export declare function getAllPublicServers(): Promise<ServerInfo[]>;
export declare function getServerById(id: string): Promise<ServerInfo | null>;
export declare function upsertServer(server: ServerInfo): Promise<void>;
export declare function deleteServer(id: string): Promise<void>;
export interface JoinRequest {
id: string;
serverId: string;
userId: string;
userPublicKey: string;
displayName: string;
status: 'pending' | 'approved' | 'rejected';
createdAt: number;
}
export declare function createJoinRequest(req: JoinRequest): Promise<void>;
export declare function getJoinRequestById(id: string): Promise<JoinRequest | null>;
export declare function getPendingRequestsForServer(serverId: string): Promise<JoinRequest[]>;
export declare function updateJoinRequestStatus(id: string, status: JoinRequest['status']): Promise<void>;
export declare function deleteStaleJoinRequests(maxAgeMs: number): Promise<void>;
//# sourceMappingURL=db.d.ts.map

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

@@ -0,0 +1 @@
{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAeA,wBAAsB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAoD5C;AAaD,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAiBlF;AAED,wBAAsB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAiBtE;AAED,wBAAsB,UAAU,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAO9D;AAMD,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAkBD,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC,CASjE;AAED,wBAAsB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAU1E;AAED,wBAAsB,YAAY,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAsBpE;AAED,wBAAsB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAY5D;AAMD,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,UAAU,CAAC;IAC5C,SAAS,EAAE,MAAM,CAAC;CACnB;AAcD,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAUvE;AAED,wBAAsB,kBAAkB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAUhF;AAED,wBAAsB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAU1F;AAED,wBAAsB,uBAAuB,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAOtG;AAED,wBAAsB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ7E"}

277
server/dist/db.js vendored Normal file
View File

@@ -0,0 +1,277 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.initDB = initDB;
exports.getUserByUsername = getUserByUsername;
exports.getUserById = getUserById;
exports.createUser = createUser;
exports.getAllPublicServers = getAllPublicServers;
exports.getServerById = getServerById;
exports.upsertServer = upsertServer;
exports.deleteServer = deleteServer;
exports.createJoinRequest = createJoinRequest;
exports.getJoinRequestById = getJoinRequestById;
exports.getPendingRequestsForServer = getPendingRequestsForServer;
exports.updateJoinRequestStatus = updateJoinRequestStatus;
exports.deleteStaleJoinRequests = deleteStaleJoinRequests;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const sql_js_1 = __importDefault(require("sql.js"));
// Simple SQLite via sql.js persisted to a single file
const DATA_DIR = path_1.default.join(process.cwd(), 'data');
const DB_FILE = path_1.default.join(DATA_DIR, 'metoyou.sqlite');
function ensureDataDir() {
if (!fs_1.default.existsSync(DATA_DIR))
fs_1.default.mkdirSync(DATA_DIR, { recursive: true });
}
let SQL = null;
let db = null;
async function initDB() {
if (db)
return;
SQL = await (0, sql_js_1.default)({ locateFile: (file) => require.resolve('sql.js/dist/sql-wasm.wasm') });
ensureDataDir();
if (fs_1.default.existsSync(DB_FILE)) {
const fileBuffer = fs_1.default.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
);
`);
db.run(`
CREATE TABLE IF NOT EXISTS servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
ownerId TEXT NOT NULL,
ownerPublicKey TEXT NOT NULL,
isPrivate INTEGER NOT NULL DEFAULT 0,
maxUsers INTEGER NOT NULL DEFAULT 0,
currentUsers INTEGER NOT NULL DEFAULT 0,
tags TEXT NOT NULL DEFAULT '[]',
createdAt INTEGER NOT NULL,
lastSeen INTEGER NOT NULL
);
`);
db.run(`
CREATE TABLE IF NOT EXISTS join_requests (
id TEXT PRIMARY KEY,
serverId TEXT NOT NULL,
userId TEXT NOT NULL,
userPublicKey TEXT NOT NULL,
displayName TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
createdAt INTEGER NOT NULL
);
`);
persist();
}
function persist() {
if (!db)
return;
const data = db.export();
const buffer = Buffer.from(data);
fs_1.default.writeFileSync(DB_FILE, buffer);
}
async function getUserByUsername(username) {
if (!db)
await initDB();
const stmt = db.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE username = ? LIMIT 1');
stmt.bind([username]);
let row = null;
if (stmt.step()) {
const r = stmt.getAsObject();
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;
}
async function getUserById(id) {
if (!db)
await initDB();
const stmt = db.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE id = ? LIMIT 1');
stmt.bind([id]);
let row = null;
if (stmt.step()) {
const r = stmt.getAsObject();
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;
}
async function createUser(user) {
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();
}
function rowToServer(r) {
return {
id: String(r.id),
name: String(r.name),
description: r.description ? String(r.description) : undefined,
ownerId: String(r.ownerId),
ownerPublicKey: String(r.ownerPublicKey),
isPrivate: !!r.isPrivate,
maxUsers: Number(r.maxUsers),
currentUsers: Number(r.currentUsers),
tags: JSON.parse(String(r.tags || '[]')),
createdAt: Number(r.createdAt),
lastSeen: Number(r.lastSeen),
};
}
async function getAllPublicServers() {
if (!db)
await initDB();
const stmt = db.prepare('SELECT * FROM servers WHERE isPrivate = 0');
const results = [];
while (stmt.step()) {
results.push(rowToServer(stmt.getAsObject()));
}
stmt.free();
return results;
}
async function getServerById(id) {
if (!db)
await initDB();
const stmt = db.prepare('SELECT * FROM servers WHERE id = ? LIMIT 1');
stmt.bind([id]);
let row = null;
if (stmt.step()) {
row = rowToServer(stmt.getAsObject());
}
stmt.free();
return row;
}
async function upsertServer(server) {
if (!db)
await initDB();
const stmt = db.prepare(`
INSERT OR REPLACE INTO servers (id, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, currentUsers, tags, createdAt, lastSeen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.bind([
server.id,
server.name,
server.description ?? null,
server.ownerId,
server.ownerPublicKey,
server.isPrivate ? 1 : 0,
server.maxUsers,
server.currentUsers,
JSON.stringify(server.tags),
server.createdAt,
server.lastSeen,
]);
stmt.step();
stmt.free();
persist();
}
async function deleteServer(id) {
if (!db)
await initDB();
const stmt = db.prepare('DELETE FROM servers WHERE id = ?');
stmt.bind([id]);
stmt.step();
stmt.free();
// Also clean up related join requests
const jStmt = db.prepare('DELETE FROM join_requests WHERE serverId = ?');
jStmt.bind([id]);
jStmt.step();
jStmt.free();
persist();
}
function rowToJoinRequest(r) {
return {
id: String(r.id),
serverId: String(r.serverId),
userId: String(r.userId),
userPublicKey: String(r.userPublicKey),
displayName: String(r.displayName),
status: String(r.status),
createdAt: Number(r.createdAt),
};
}
async function createJoinRequest(req) {
if (!db)
await initDB();
const stmt = db.prepare(`
INSERT INTO join_requests (id, serverId, userId, userPublicKey, displayName, status, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
stmt.bind([req.id, req.serverId, req.userId, req.userPublicKey, req.displayName, req.status, req.createdAt]);
stmt.step();
stmt.free();
persist();
}
async function getJoinRequestById(id) {
if (!db)
await initDB();
const stmt = db.prepare('SELECT * FROM join_requests WHERE id = ? LIMIT 1');
stmt.bind([id]);
let row = null;
if (stmt.step()) {
row = rowToJoinRequest(stmt.getAsObject());
}
stmt.free();
return row;
}
async function getPendingRequestsForServer(serverId) {
if (!db)
await initDB();
const stmt = db.prepare('SELECT * FROM join_requests WHERE serverId = ? AND status = ?');
stmt.bind([serverId, 'pending']);
const results = [];
while (stmt.step()) {
results.push(rowToJoinRequest(stmt.getAsObject()));
}
stmt.free();
return results;
}
async function updateJoinRequestStatus(id, status) {
if (!db)
await initDB();
const stmt = db.prepare('UPDATE join_requests SET status = ? WHERE id = ?');
stmt.bind([status, id]);
stmt.step();
stmt.free();
persist();
}
async function deleteStaleJoinRequests(maxAgeMs) {
if (!db)
await initDB();
const cutoff = Date.now() - maxAgeMs;
const stmt = db.prepare('DELETE FROM join_requests WHERE createdAt < ?');
stmt.bind([cutoff]);
stmt.step();
stmt.free();
persist();
}
//# sourceMappingURL=db.js.map

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

File diff suppressed because one or more lines are too long

305
server/dist/index.js vendored
View File

@@ -12,71 +12,85 @@ 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));
}
}
// Database
const crypto_1 = __importDefault(require("crypto"));
const db_1 = require("./db");
function hashPassword(pw) { return crypto_1.default.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 (0, db_1.getAllPublicServers)();
res.json({
status: 'ok',
timestamp: Date.now(),
serverCount: servers.size,
serverCount: allServers.length,
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) => {
// Time endpoint for clock synchronization
app.get('/api/time', (req, res) => {
res.json({ now: Date.now() });
});
// Image proxy to allow rendering external images within CSP (img-src 'self' data: blob:)
app.get('/api/image-proxy', async (req, res) => {
try {
const url = String(req.query.url || '');
if (!/^https?:\/\//i.test(url)) {
return res.status(400).json({ error: 'Invalid URL' });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const response = await fetch(url, { redirect: 'follow', signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
return res.status(response.status).end();
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.toLowerCase().startsWith('image/')) {
return res.status(415).json({ error: 'Unsupported content type' });
}
const arrayBuffer = await response.arrayBuffer();
const MAX_BYTES = 8 * 1024 * 1024; // 8MB limit
if (arrayBuffer.byteLength > MAX_BYTES) {
return res.status(413).json({ error: 'Image too large' });
}
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.send(Buffer.from(arrayBuffer));
}
catch (err) {
if (err?.name === 'AbortError') {
return res.status(504).json({ error: 'Timeout fetching image' });
}
console.error('Image proxy error:', err);
res.status(502).json({ error: 'Failed to fetch image' });
}
});
// Auth
app.post('/api/users/register', async (req, res) => {
const { username, password, displayName } = req.body;
if (!username || !password)
return res.status(400).json({ error: 'Missing username/password' });
if (authUsers.find(u => u.username === username))
const exists = await (0, db_1.getUserByUsername)(username);
if (exists)
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();
await (0, db_1.createUser)(user);
res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName });
});
app.post('/api/users/login', (req, res) => {
app.post('/api/users/login', async (req, res) => {
const { username, password } = req.body;
const user = authUsers.find(u => u.username === username && u.passwordHash === hashPassword(password));
if (!user)
const user = await (0, db_1.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 (0, db_1.getAllPublicServers)();
results = results
.filter(s => {
if (q) {
const query = String(q).toLowerCase();
@@ -92,18 +106,16 @@ 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));
res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) });
});
// 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 || (0, uuid_1.v4)();
const server = {
id,
@@ -118,15 +130,14 @@ app.post('/api/servers', (req, res) => {
createdAt: Date.now(),
lastSeen: Date.now(),
};
servers.set(id, server);
saveServers();
await (0, db_1.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 (0, db_1.getServerById)(id);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
@@ -134,15 +145,14 @@ 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();
await (0, db_1.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 (0, db_1.getServerById)(id);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
@@ -150,30 +160,28 @@ app.post('/api/servers/:id/heartbeat', (req, res) => {
if (typeof currentUsers === 'number') {
server.currentUsers = currentUsers;
}
servers.set(id, server);
saveServers();
await (0, db_1.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 (0, db_1.getServerById)(id);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
if (server.ownerId !== ownerId) {
return res.status(403).json({ error: 'Not authorized' });
}
servers.delete(id);
saveServers();
await (0, db_1.deleteServer)(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 (0, db_1.getServerById)(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
@@ -187,7 +195,7 @@ app.post('/api/servers/:id/join', (req, res) => {
status: server.isPrivate ? 'pending' : 'approved',
createdAt: Date.now(),
};
joinRequests.set(requestId, request);
await (0, db_1.createJoinRequest)(request);
// Notify server owner via WebSocket
if (server.isPrivate) {
notifyServerOwner(server.ownerId, {
@@ -198,70 +206,72 @@ app.post('/api/servers/:id/join', (req, res) => {
res.status(201).json(request);
});
// 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 (0, db_1.getServerById)(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
if (server.ownerId !== ownerId) {
return res.status(403).json({ error: 'Not authorized' });
}
const requests = Array.from(joinRequests.values())
.filter(r => r.serverId === serverId && r.status === 'pending');
const requests = await (0, db_1.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 (0, db_1.getJoinRequestById)(id);
if (!request) {
return res.status(404).json({ error: 'Request not found' });
}
const server = servers.get(request.serverId);
const server = await (0, db_1.getServerById)(request.serverId);
if (!server || server.ownerId !== ownerId) {
return res.status(403).json({ error: 'Not authorized' });
}
request.status = status;
joinRequests.set(id, request);
await (0, db_1.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 = (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 });
const connectionId = (0, uuid_1.v4)();
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() });
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
handleWebSocketMessage(oderId, message);
handleWebSocketMessage(connectionId, 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);
const user = connectedUsers.get(connectionId);
if (user) {
// Notify all servers the user was a member of
user.serverIds.forEach((sid) => {
broadcastToServer(sid, {
type: 'user_left',
oderId: user.oderId,
displayName: user.displayName,
serverId: sid,
}, user.oderId);
});
}
connectedUsers.delete(oderId);
connectedUsers.delete(connectionId);
});
// Send connection acknowledgment
ws.send(JSON.stringify({ type: 'connected', oderId }));
// Send connection acknowledgment with the connectionId (client will identify with their actual oderId)
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
});
function handleWebSocketMessage(connectionId, message) {
const user = connectedUsers.get(connectionId);
@@ -276,38 +286,68 @@ function handleWebSocketMessage(connectionId, message) {
connectedUsers.set(connectionId, user);
console.log(`User identified: ${user.displayName} (${user.oderId})`);
break;
case 'join_server':
user.serverId = message.serverId;
case 'join_server': {
const sid = message.serverId;
const isNew = !user.serverIds.has(sid);
user.serverIds.add(sid);
user.viewedServerId = sid;
connectedUsers.set(connectionId, user);
console.log(`User ${user.displayName} (${user.oderId}) joined server ${message.serverId}`);
// Get list of current users in server (exclude this user by oderId)
console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`);
// Always send the current user list for this server
const usersInServer = Array.from(connectedUsers.values())
.filter(u => u.serverId === message.serverId && u.oderId !== user.oderId)
.map(u => ({ oderId: u.oderId, displayName: u.displayName }));
console.log(`Sending server_users to ${user.displayName}:`, usersInServer);
.filter(u => u.serverIds.has(sid) && u.oderId !== user.oderId && u.displayName)
.map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' }));
console.log(`Sending server_users to ${user.displayName || 'Anonymous'}:`, usersInServer);
user.ws.send(JSON.stringify({
type: 'server_users',
serverId: sid,
users: usersInServer,
}));
// 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',
// Only broadcast user_joined if this is a brand-new join (not a re-view)
if (isNew) {
broadcastToServer(sid, {
type: 'user_joined',
oderId: user.oderId,
displayName: user.displayName,
displayName: user.displayName || 'Anonymous',
serverId: sid,
}, user.oderId);
}
break;
}
case 'view_server': {
// Just switch the viewed server without joining/leaving
const viewSid = message.serverId;
user.viewedServerId = viewSid;
connectedUsers.set(connectionId, user);
console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`);
// Send current user list for the viewed server
const viewUsers = Array.from(connectedUsers.values())
.filter(u => u.serverIds.has(viewSid) && u.oderId !== user.oderId && u.displayName)
.map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' }));
user.ws.send(JSON.stringify({
type: 'server_users',
serverId: viewSid,
users: viewUsers,
}));
break;
}
case 'leave_server': {
const leaveSid = message.serverId || user.viewedServerId;
if (leaveSid) {
user.serverIds.delete(leaveSid);
if (user.viewedServerId === leaveSid) {
user.viewedServerId = undefined;
}
connectedUsers.set(connectionId, user);
broadcastToServer(leaveSid, {
type: 'user_left',
oderId: user.oderId,
displayName: user.displayName || 'Anonymous',
serverId: leaveSid,
}, user.oderId);
}
break;
}
case 'offer':
case 'answer':
case 'ice_candidate':
@@ -325,11 +365,13 @@ function handleWebSocketMessage(connectionId, message) {
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':
case 'chat_message': {
// Broadcast chat message to all users in the server
if (user.serverId) {
broadcastToServer(user.serverId, {
const chatSid = message.serverId || user.viewedServerId;
if (chatSid && user.serverIds.has(chatSid)) {
broadcastToServer(chatSid, {
type: 'chat_message',
serverId: chatSid,
message: message.message,
senderId: user.oderId,
senderName: user.displayName,
@@ -337,16 +379,20 @@ function handleWebSocketMessage(connectionId, message) {
});
}
break;
case 'typing':
}
case 'typing': {
// Broadcast typing indicator
if (user.serverId) {
broadcastToServer(user.serverId, {
const typingSid = message.serverId || user.viewedServerId;
if (typingSid && user.serverIds.has(typingSid)) {
broadcastToServer(typingSid, {
type: 'user_typing',
serverId: typingSid,
oderId: user.oderId,
displayName: user.displayName,
}, user.oderId);
}
break;
}
default:
console.log('Unknown message type:', message.type);
}
@@ -354,7 +400,7 @@ function handleWebSocketMessage(connectionId, message) {
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) {
if (user.serverIds.has(serverId) && user.oderId !== excludeOderId) {
console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
user.ws.send(JSON.stringify(message));
}
@@ -375,21 +421,18 @@ function notifyUser(oderId, 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)
// 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);
}
});
(0, db_1.deleteStaleJoinRequests)(24 * 60 * 60 * 1000).catch(err => console.error('Failed to clean up stale join requests:', err));
}, 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();
(0, db_1.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}`);
});
}).catch((err) => {
console.error('Failed to initialize database:', err);
process.exit(1);
});
//# sourceMappingURL=index.js.map

File diff suppressed because one or more lines are too long

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.3.1",
"express": "^4.18.2",
"sql.js": "^1.9.0",
"uuid": "^9.0.0",

View File

@@ -36,6 +36,34 @@ export async function initDB(): Promise<void> {
);
`);
db.run(`
CREATE TABLE IF NOT EXISTS servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
ownerId TEXT NOT NULL,
ownerPublicKey TEXT NOT NULL,
isPrivate INTEGER NOT NULL DEFAULT 0,
maxUsers INTEGER NOT NULL DEFAULT 0,
currentUsers INTEGER NOT NULL DEFAULT 0,
tags TEXT NOT NULL DEFAULT '[]',
createdAt INTEGER NOT NULL,
lastSeen INTEGER NOT NULL
);
`);
db.run(`
CREATE TABLE IF NOT EXISTS join_requests (
id TEXT PRIMARY KEY,
serverId TEXT NOT NULL,
userId TEXT NOT NULL,
userPublicKey TEXT NOT NULL,
displayName TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
createdAt INTEGER NOT NULL
);
`);
persist();
}
@@ -46,6 +74,10 @@ function persist(): void {
fs.writeFileSync(DB_FILE, buffer);
}
/* ------------------------------------------------------------------ */
/* Auth Users */
/* ------------------------------------------------------------------ */
export interface AuthUser {
id: string;
username: string;
@@ -100,3 +132,179 @@ export async function createUser(user: AuthUser): Promise<void> {
stmt.free();
persist();
}
/* ------------------------------------------------------------------ */
/* Servers */
/* ------------------------------------------------------------------ */
export interface ServerInfo {
id: string;
name: string;
description?: string;
ownerId: string;
ownerPublicKey: string;
isPrivate: boolean;
maxUsers: number;
currentUsers: number;
tags: string[];
createdAt: number;
lastSeen: number;
}
function rowToServer(r: any): ServerInfo {
return {
id: String(r.id),
name: String(r.name),
description: r.description ? String(r.description) : undefined,
ownerId: String(r.ownerId),
ownerPublicKey: String(r.ownerPublicKey),
isPrivate: !!r.isPrivate,
maxUsers: Number(r.maxUsers),
currentUsers: Number(r.currentUsers),
tags: JSON.parse(String(r.tags || '[]')),
createdAt: Number(r.createdAt),
lastSeen: Number(r.lastSeen),
};
}
export async function getAllPublicServers(): Promise<ServerInfo[]> {
if (!db) await initDB();
const stmt: any = db!.prepare('SELECT * FROM servers WHERE isPrivate = 0');
const results: ServerInfo[] = [];
while (stmt.step()) {
results.push(rowToServer(stmt.getAsObject()));
}
stmt.free();
return results;
}
export async function getServerById(id: string): Promise<ServerInfo | null> {
if (!db) await initDB();
const stmt: any = db!.prepare('SELECT * FROM servers WHERE id = ? LIMIT 1');
stmt.bind([id]);
let row: ServerInfo | null = null;
if (stmt.step()) {
row = rowToServer(stmt.getAsObject());
}
stmt.free();
return row;
}
export async function upsertServer(server: ServerInfo): Promise<void> {
if (!db) await initDB();
const stmt = db!.prepare(`
INSERT OR REPLACE INTO servers (id, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, currentUsers, tags, createdAt, lastSeen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.bind([
server.id,
server.name,
server.description ?? null,
server.ownerId,
server.ownerPublicKey,
server.isPrivate ? 1 : 0,
server.maxUsers,
server.currentUsers,
JSON.stringify(server.tags),
server.createdAt,
server.lastSeen,
]);
stmt.step();
stmt.free();
persist();
}
export async function deleteServer(id: string): Promise<void> {
if (!db) await initDB();
const stmt = db!.prepare('DELETE FROM servers WHERE id = ?');
stmt.bind([id]);
stmt.step();
stmt.free();
// Also clean up related join requests
const jStmt = db!.prepare('DELETE FROM join_requests WHERE serverId = ?');
jStmt.bind([id]);
jStmt.step();
jStmt.free();
persist();
}
/* ------------------------------------------------------------------ */
/* Join Requests */
/* ------------------------------------------------------------------ */
export interface JoinRequest {
id: string;
serverId: string;
userId: string;
userPublicKey: string;
displayName: string;
status: 'pending' | 'approved' | 'rejected';
createdAt: number;
}
function rowToJoinRequest(r: any): JoinRequest {
return {
id: String(r.id),
serverId: String(r.serverId),
userId: String(r.userId),
userPublicKey: String(r.userPublicKey),
displayName: String(r.displayName),
status: String(r.status) as JoinRequest['status'],
createdAt: Number(r.createdAt),
};
}
export async function createJoinRequest(req: JoinRequest): Promise<void> {
if (!db) await initDB();
const stmt = db!.prepare(`
INSERT INTO join_requests (id, serverId, userId, userPublicKey, displayName, status, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
stmt.bind([req.id, req.serverId, req.userId, req.userPublicKey, req.displayName, req.status, req.createdAt]);
stmt.step();
stmt.free();
persist();
}
export async function getJoinRequestById(id: string): Promise<JoinRequest | null> {
if (!db) await initDB();
const stmt: any = db!.prepare('SELECT * FROM join_requests WHERE id = ? LIMIT 1');
stmt.bind([id]);
let row: JoinRequest | null = null;
if (stmt.step()) {
row = rowToJoinRequest(stmt.getAsObject());
}
stmt.free();
return row;
}
export async function getPendingRequestsForServer(serverId: string): Promise<JoinRequest[]> {
if (!db) await initDB();
const stmt: any = db!.prepare('SELECT * FROM join_requests WHERE serverId = ? AND status = ?');
stmt.bind([serverId, 'pending']);
const results: JoinRequest[] = [];
while (stmt.step()) {
results.push(rowToJoinRequest(stmt.getAsObject()));
}
stmt.free();
return results;
}
export async function updateJoinRequestStatus(id: string, status: JoinRequest['status']): Promise<void> {
if (!db) await initDB();
const stmt = db!.prepare('UPDATE join_requests SET status = ? WHERE id = ?');
stmt.bind([status, id]);
stmt.step();
stmt.free();
persist();
}
export async function deleteStaleJoinRequests(maxAgeMs: number): Promise<void> {
if (!db) await initDB();
const cutoff = Date.now() - maxAgeMs;
const stmt = db!.prepare('DELETE FROM join_requests WHERE createdAt < ?');
stmt.bind([cutoff]);
stmt.step();
stmt.free();
persist();
}

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