Imrpove chat with gifs, videos, music player, redesigns and improved filesharing errors

This commit is contained in:
2026-03-06 04:47:07 +01:00
parent 2d84fbd91a
commit fe2347b54e
65 changed files with 3593 additions and 1030 deletions

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

@@ -1,43 +0,0 @@
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

View File

@@ -1 +0,0 @@
{"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
View File

@@ -1,277 +0,0 @@
"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

File diff suppressed because one or more lines are too long

View File

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

View File

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

438
server/dist/index.js vendored
View File

@@ -1,438 +0,0 @@
"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 connectedUsers = new Map();
// 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', async (req, res) => {
const allServers = await (0, db_1.getAllPublicServers)();
res.json({
status: 'ok',
timestamp: Date.now(),
serverCount: allServers.length,
connectedUsers: connectedUsers.size,
});
});
// 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' });
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() };
await (0, db_1.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;
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', async (req, res) => {
const { q, tags, limit = 20, offset = 0 } = req.query;
let results = await (0, db_1.getAllPublicServers)();
results = results
.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;
});
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', 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' });
}
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(),
};
await (0, db_1.upsertServer)(server);
res.status(201).json(server);
});
// Update server
app.put('/api/servers/:id', async (req, res) => {
const { id } = req.params;
const { ownerId, ...updates } = req.body;
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' });
}
const updated = { ...server, ...updates, lastSeen: Date.now() };
await (0, db_1.upsertServer)(updated);
res.json(updated);
});
// Heartbeat - keep server alive
app.post('/api/servers/:id/heartbeat', async (req, res) => {
const { id } = req.params;
const { currentUsers } = req.body;
const server = await (0, db_1.getServerById)(id);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
server.lastSeen = Date.now();
if (typeof currentUsers === 'number') {
server.currentUsers = currentUsers;
}
await (0, db_1.upsertServer)(server);
res.json({ ok: true });
});
// Remove server
app.delete('/api/servers/:id', async (req, res) => {
const { id } = req.params;
const { ownerId } = req.body;
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' });
}
await (0, db_1.deleteServer)(id);
res.json({ ok: true });
});
// Request to join a server
app.post('/api/servers/:id/join', async (req, res) => {
const { id: serverId } = req.params;
const { userId, userPublicKey, displayName } = req.body;
const server = await (0, db_1.getServerById)(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(),
};
await (0, db_1.createJoinRequest)(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', async (req, res) => {
const { id: serverId } = req.params;
const { ownerId } = req.query;
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 = await (0, db_1.getPendingRequestsForServer)(serverId);
res.json({ requests });
});
// Approve/reject join request
app.put('/api/requests/:id', async (req, res) => {
const { id } = req.params;
const { ownerId, status } = req.body;
const request = await (0, db_1.getJoinRequestById)(id);
if (!request) {
return res.status(404).json({ error: 'Request not found' });
}
const server = await (0, db_1.getServerById)(request.serverId);
if (!server || server.ownerId !== ownerId) {
return res.status(403).json({ error: 'Not authorized' });
}
await (0, db_1.updateJoinRequestStatus)(id, status);
const updated = { ...request, status };
// Notify the requester
notifyUser(request.userId, {
type: 'request_update',
request: updated,
});
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 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(connectionId, message);
}
catch (err) {
console.error('Invalid WebSocket message:', err);
}
});
ws.on('close', () => {
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(connectionId);
});
// 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);
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': {
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 || '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.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,
}));
// 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 || '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':
// 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
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,
timestamp: Date.now(),
});
}
break;
}
case 'typing': {
// Broadcast typing indicator
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);
}
}
function broadcastToServer(serverId, message, excludeOderId) {
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
connectedUsers.forEach((user) => {
if (user.serverIds.has(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 stale join requests periodically (older than 24 h)
setInterval(() => {
(0, db_1.deleteStaleJoinRequests)(24 * 60 * 60 * 1000).catch(err => console.error('Failed to clean up stale join requests:', err));
}, 60 * 1000);
(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

@@ -0,0 +1,72 @@
import fs from 'fs';
import path from 'path';
export interface ServerVariablesConfig {
klipyApiKey: string;
}
const DATA_DIR = path.join(process.cwd(), 'data');
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
function normalizeKlipyApiKey(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
if (!fs.existsSync(VARIABLES_FILE)) {
return { rawContents: '', parsed: {} };
}
const rawContents = fs.readFileSync(VARIABLES_FILE, 'utf8');
if (!rawContents.trim()) {
return { rawContents, parsed: {} };
}
try {
const parsed = JSON.parse(rawContents) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return { rawContents, parsed: parsed as Record<string, unknown> };
}
} catch (error) {
console.warn('[Config] Failed to parse variables.json. Recreating it with defaults.', error);
}
return { rawContents, parsed: {} };
}
export function getVariablesConfigPath(): string {
return VARIABLES_FILE;
}
export function ensureVariablesConfig(): ServerVariablesConfig {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const { rawContents, parsed } = readRawVariables();
const normalized = {
...parsed,
klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey)
};
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
if (!fs.existsSync(VARIABLES_FILE) || rawContents !== nextContents) {
fs.writeFileSync(VARIABLES_FILE, nextContents, 'utf8');
}
return { klipyApiKey: normalized.klipyApiKey };
}
export function getVariablesConfig(): ServerVariablesConfig {
return ensureVariablesConfig();
}
export function getKlipyApiKey(): string {
return getVariablesConfig().klipyApiKey;
}
export function hasKlipyApiKey(): boolean {
return getKlipyApiKey().length > 0;
}

View File

@@ -32,11 +32,12 @@ export async function initDatabase(): Promise<void> {
applicationDataSource = new DataSource({
type: 'sqljs',
database,
entities: [AuthUserEntity, ServerEntity, JoinRequestEntity],
migrations: [
path.join(__dirname, '..', 'migrations', '*.js'),
path.join(__dirname, '..', 'migrations', '*.ts')
entities: [
AuthUserEntity,
ServerEntity,
JoinRequestEntity
],
migrations: [path.join(__dirname, '..', 'migrations', '*.js'), path.join(__dirname, '..', 'migrations', '*.ts')],
synchronize: false,
logging: false,
autoSave: true,

View File

@@ -11,6 +11,11 @@ dotenv.config({ path: path.resolve(__dirname, '..', '..', '.env') });
import { initDatabase } from './db';
import { deleteStaleJoinRequests } from './cqrs';
import { createApp } from './app';
import {
ensureVariablesConfig,
getVariablesConfigPath,
hasKlipyApiKey
} from './config/variables';
import { setupWebSocket } from './websocket';
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
@@ -38,6 +43,13 @@ function buildServer(app: ReturnType<typeof createApp>) {
}
async function bootstrap(): Promise<void> {
ensureVariablesConfig();
console.log('[Config] Variables loaded from:', getVariablesConfigPath());
if (!hasKlipyApiKey()) {
console.log('[KLIPY] API key not configured. GIF search is disabled.');
}
await initDatabase();
const app = createApp();

View File

@@ -1,5 +1,6 @@
import { Express } from 'express';
import healthRouter from './health';
import klipyRouter from './klipy';
import proxyRouter from './proxy';
import usersRouter from './users';
import serversRouter from './servers';
@@ -7,6 +8,7 @@ import joinRequestsRouter from './join-requests';
export function registerRoutes(app: Express): void {
app.use('/api', healthRouter);
app.use('/api', klipyRouter);
app.use('/api', proxyRouter);
app.use('/api/users', usersRouter);
app.use('/api/servers', serversRouter);

221
server/src/routes/klipy.ts Normal file
View File

@@ -0,0 +1,221 @@
/* eslint-disable complexity, @typescript-eslint/no-explicit-any */
import { Router } from 'express';
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
const router = Router();
const KLIPY_API_BASE_URL = 'https://api.klipy.com/api/v1';
const REQUEST_TIMEOUT_MS = 8000;
const DEFAULT_PAGE = 1;
const DEFAULT_PER_PAGE = 24;
const MAX_PER_PAGE = 50;
interface NormalizedMediaMeta {
url: string;
width?: number;
height?: number;
}
interface NormalizedKlipyGif {
id: string;
slug: string;
title?: string;
url: string;
previewUrl: string;
width: number;
height: number;
}
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
for (const value of values) {
if (value != null)
return value;
}
return undefined;
}
function sanitizeString(value: unknown): string | undefined {
if (typeof value !== 'string')
return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function toPositiveNumber(value: unknown): number | undefined {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function clampPositiveInt(value: unknown, fallback: number, max = Number.MAX_SAFE_INTEGER): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 1)
return fallback;
return Math.min(Math.floor(parsed), max);
}
function normalizeMediaMeta(candidate: unknown): NormalizedMediaMeta | null {
if (!candidate)
return null;
if (typeof candidate === 'string') {
return { url: candidate };
}
if (typeof candidate === 'object' && candidate !== null) {
const url = sanitizeString((candidate as { url?: unknown }).url);
if (!url)
return null;
return {
url,
width: toPositiveNumber((candidate as { width?: unknown }).width),
height: toPositiveNumber((candidate as { height?: unknown }).height)
};
}
return null;
}
function pickGifMeta(sizeVariant: unknown): NormalizedMediaMeta | null {
const candidate = sizeVariant as {
gif?: unknown;
webp?: unknown;
} | undefined;
return normalizeMediaMeta(candidate?.gif) ?? normalizeMediaMeta(candidate?.webp);
}
function normalizeGifItem(item: any): NormalizedKlipyGif | null {
if (!item || typeof item !== 'object' || item.type === 'ad')
return null;
const lowVariant = pickFirst(item.file?.md, item.file?.sm, item.file?.xs, item.file?.hd);
const highVariant = pickFirst(item.file?.hd, item.file?.md, item.file?.sm, item.file?.xs);
const lowMeta = pickGifMeta(lowVariant);
const highMeta = pickGifMeta(highVariant);
const selectedMeta = highMeta ?? lowMeta;
const slug = sanitizeString(item.slug) ?? sanitizeString(item.id);
if (!slug || !selectedMeta?.url)
return null;
return {
id: slug,
slug,
title: sanitizeString(item.title),
url: selectedMeta.url,
previewUrl: lowMeta?.url ?? selectedMeta.url,
width: selectedMeta.width ?? lowMeta?.width ?? 0,
height: selectedMeta.height ?? lowMeta?.height ?? 0
};
}
function extractErrorMessage(payload: unknown): string | null {
if (!payload)
return null;
if (typeof payload === 'string')
return payload.slice(0, 240);
if (typeof payload === 'object' && payload !== null) {
const data = payload as { error?: unknown; message?: unknown };
if (typeof data.error === 'string')
return data.error;
if (typeof data.message === 'string')
return data.message;
}
return null;
}
router.get('/klipy/config', (_req, res) => {
res.json({ enabled: hasKlipyApiKey() });
});
router.get('/klipy/gifs', async (req, res) => {
if (!hasKlipyApiKey()) {
return res.status(503).json({ error: 'KLIPY is not configured on this server.' });
}
try {
const query = sanitizeString(req.query.q) ?? '';
const page = clampPositiveInt(req.query.page, DEFAULT_PAGE);
const perPage = clampPositiveInt(req.query.per_page, DEFAULT_PER_PAGE, MAX_PER_PAGE);
const customerId = sanitizeString(req.query.customer_id);
const locale = sanitizeString(req.query.locale);
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage)
});
if (query)
params.set('q', query);
if (customerId)
params.set('customer_id', customerId);
if (locale)
params.set('locale', locale);
const endpoint = query ? 'search' : 'trending';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const response = await fetch(
`${KLIPY_API_BASE_URL}/${encodeURIComponent(getKlipyApiKey())}/gifs/${endpoint}?${params.toString()}`,
{
headers: { accept: 'application/json' },
signal: controller.signal
}
);
clearTimeout(timeout);
const text = await response.text();
let payload: unknown = null;
if (text) {
try {
payload = JSON.parse(text) as unknown;
} catch {
payload = text;
}
}
if (!response.ok) {
return res.status(response.status).json({
error: extractErrorMessage(payload) || 'Failed to fetch GIFs from KLIPY.'
});
}
const rawItems = Array.isArray((payload as any)?.data?.data)
? (payload as any).data.data
: [];
const results = rawItems
.map((item: unknown) => normalizeGifItem(item))
.filter((item: NormalizedKlipyGif | null): item is NormalizedKlipyGif => !!item);
res.json({
enabled: true,
results,
hasNext: (payload as any)?.data?.has_next === true
});
} catch (error) {
if ((error as { name?: string })?.name === 'AbortError') {
return res.status(504).json({ error: 'KLIPY request timed out.' });
}
console.error('KLIPY GIF route error:', error);
res.status(502).json({ error: 'Failed to fetch GIFs from KLIPY.' });
}
});
export default router;

View File

@@ -66,13 +66,14 @@ router.post('/', async (req, res) => {
router.put('/:id', async (req, res) => {
const { id } = req.params;
const { ownerId, ...updates } = req.body;
const { currentOwnerId, ...updates } = req.body;
const existing = await getServerById(id);
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
if (!existing)
return res.status(404).json({ error: 'Server not found' });
if (existing.ownerId !== ownerId)
if (existing.ownerId !== authenticatedOwnerId)
return res.status(403).json({ error: 'Not authorized' });
const server: ServerPayload = { ...existing, ...updates, lastSeen: Date.now() };