const express = require('express'); const { WebSocketServer } = require('ws'); const { createServer } = require('http'); const { randomBytes, randomUUID } = require('crypto'); const multer = require('multer'); const fs = require('fs'); const path = require('path'); // ─── Config ─────────────────────────────────────────────────────────────────── const PORT = process.env.PORT || 3000; const MAX_FILE_BYTES = parseInt(process.env.MAX_FILE_SIZE_MB ?? '25') * 1024 * 1024; const UPLOADS_DIR = process.env.UPLOADS_DIR ?? '/data/files'; const QUEUE_DIR = process.env.QUEUE_DIR ?? '/data/queue'; const PAIRING_DIR = process.env.PAIRING_DIR ?? '/data/pairing'; const PAIRING_TTL_MS = 5 * 60 * 1000; const QUEUE_TTL_MS = 7 * 24 * 60 * 60 * 1000; const QUEUE_MAX = 500; const FILE_TTL_DAYS = positiveInteger(process.env.FILE_TTL_DAYS, 30); const FILE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; const ADMIN_TOKEN = process.env.ADMIN_TOKEN?.trim() ?? ''; fs.mkdirSync(UPLOADS_DIR, { recursive: true }); fs.mkdirSync(QUEUE_DIR, { recursive: true }); fs.mkdirSync(PAIRING_DIR, { recursive: true }); const app = express(); app.use(express.json()); // ─── File storage ───────────────────────────────────────────────────────────── const storage = multer.diskStorage({ destination: UPLOADS_DIR, filename: (_req, _file, cb) => cb(null, randomUUID()), }); const upload = multer({ storage, limits: { fileSize: MAX_FILE_BYTES } }); // ─── In-memory state ────────────────────────────────────────────────────────── const pairingSessions = new Map(); // code -> session const connections = new Map(); // peerID -> { ws, name } const fileRegistry = new Map(); // fileID -> { diskPath, originalName, size } function positiveInteger(value, fallback) { const parsed = Number.parseInt(value ?? '', 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } function cleanupFilesOlderThan(days = FILE_TTL_DAYS) { const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; let deletedFiles = 0; let deletedBytes = 0; for (const entry of fs.readdirSync(UPLOADS_DIR, { withFileTypes: true })) { if (!entry.isFile()) continue; const diskPath = path.join(UPLOADS_DIR, entry.name); try { const stats = fs.statSync(diskPath); if (stats.mtimeMs >= cutoff) continue; fs.unlinkSync(diskPath); fileRegistry.delete(entry.name); deletedFiles += 1; deletedBytes += stats.size; } catch (error) { console.error(`[cleanup] failed to remove ${entry.name}: ${error.message}`); } } return { days, deletedFiles, deletedBytes }; } function logCleanupResult(result, source) { if (result.deletedFiles > 0) { console.log( `[cleanup] ${source}: removed ${result.deletedFiles} file(s), ${result.deletedBytes} byte(s), older than ${result.days} day(s)` ); } } logCleanupResult(cleanupFilesOlderThan(), 'startup'); setInterval(() => { logCleanupResult(cleanupFilesOlderThan(), 'scheduled'); }, FILE_CLEANUP_INTERVAL_MS); // ─── Persistent queue (disk-backed) ────────────────────────────────────────── // // Layout on disk: // /data/queue/{peerID}/{messageID}.json // // A message is written to disk the moment it is queued. // It is deleted from disk the moment it is delivered. // On server startup all existing files are loaded back into memory. function queueDir(peerID) { return path.join(QUEUE_DIR, peerID); } function queuePath(peerID, messageID) { return path.join(queueDir(peerID), `${messageID}.json`); } function persistMessage(peerID, messageID, envelope) { fs.mkdirSync(queueDir(peerID), { recursive: true }); fs.writeFileSync( queuePath(peerID, messageID), JSON.stringify({ envelope, queuedAt: Date.now() }) ); } function deletePersistedMessage(peerID, messageID) { try { fs.unlinkSync(queuePath(peerID, messageID)); } catch (_) {} } function loadQueueFromDisk() { const queues = new Map(); if (!fs.existsSync(QUEUE_DIR)) return queues; for (const peerID of fs.readdirSync(QUEUE_DIR)) { const dir = queueDir(peerID); if (!fs.statSync(dir).isDirectory()) continue; const entries = []; for (const file of fs.readdirSync(dir)) { if (!file.endsWith('.json')) continue; try { const raw = fs.readFileSync(path.join(dir, file), 'utf8'); const { envelope, queuedAt } = JSON.parse(raw); // Drop messages older than TTL if (Date.now() - queuedAt > QUEUE_TTL_MS) { fs.unlinkSync(path.join(dir, file)); continue; } const messageID = file.replace('.json', ''); entries.push({ messageID, envelope, queuedAt }); } catch (_) {} } if (entries.length > 0) { entries.sort((a, b) => a.queuedAt - b.queuedAt); queues.set(peerID, entries); } } return queues; } // In-memory queue mirrors disk — both are always in sync const offlineQueues = loadQueueFromDisk(); console.log(`[queue] loaded ${[...offlineQueues.values()].reduce((s, q) => s + q.length, 0)} queued messages from disk`); function enqueue(peerID, messageID, envelope) { if (!offlineQueues.has(peerID)) offlineQueues.set(peerID, []); const queue = offlineQueues.get(peerID); if (queue.length >= QUEUE_MAX) { const dropped = queue.shift(); deletePersistedMessage(peerID, dropped.messageID); } queue.push({ messageID, envelope, queuedAt: Date.now() }); persistMessage(peerID, messageID, envelope); } function flushQueue(peerID, ws) { const queue = offlineQueues.get(peerID); if (!queue || queue.length === 0) return; const now = Date.now(); for (const { messageID, envelope, queuedAt } of queue) { if (now - queuedAt < QUEUE_TTL_MS) send(ws, envelope); deletePersistedMessage(peerID, messageID); } offlineQueues.delete(peerID); } // ─── Persistent pairing sessions (disk-backed) ──────────────────────────────── // // Pairing sessions are written to disk so tokens survive a server restart. // Without this, everyone would need to re-pair after every server update. function pairingPath(code) { return path.join(PAIRING_DIR, `${code}.json`); } function persistSession(session) { fs.writeFileSync(pairingPath(session.code), JSON.stringify(session)); } function deleteSession(code) { try { fs.unlinkSync(pairingPath(code)); } catch (_) {} } function loadSessionsFromDisk() { if (!fs.existsSync(PAIRING_DIR)) return; const now = Date.now(); for (const file of fs.readdirSync(PAIRING_DIR)) { if (!file.endsWith('.json')) continue; try { const session = JSON.parse(fs.readFileSync(path.join(PAIRING_DIR, file), 'utf8')); if (session.expiresAt && session.expiresAt < now) { fs.unlinkSync(path.join(PAIRING_DIR, file)); continue; } // Permanent sessions (joined pairs) have no expiry — keep them forever pairingSessions.set(session.code, session); } catch (_) {} } } loadSessionsFromDisk(); console.log(`[pairing] loaded ${pairingSessions.size} sessions from disk`); // ─── Helpers ────────────────────────────────────────────────────────────────── function generateCode() { const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const bytes = randomBytes(6); let suffix = ''; for (let i = 0; i < 6; i++) suffix += alphabet[bytes[i] % alphabet.length]; return 'FCHT-' + suffix; } function generateToken() { return randomBytes(32).toString('hex'); } function sessionByToken(token) { for (const s of pairingSessions.values()) { if (s.creatorToken === token || s.joinerToken === token) return s; } return null; } function peerIDFromToken(session, token) { return session.creatorToken === token ? session.creatorID : session.joinerID; } function peerPartnerID(session, peerID) { return session.creatorID === peerID ? session.joinerID : session.creatorID; } function send(ws, obj) { if (ws.readyState === 1) ws.send(JSON.stringify(obj)); } function cleanExpiredSessions() { const now = Date.now(); for (const [code, s] of pairingSessions) { // Only remove pending (not yet joined) sessions that expired if (!s.joinerID && s.expiresAt < now) { pairingSessions.delete(code); deleteSession(code); } } } setInterval(cleanExpiredSessions, 60_000); // ─── Auth middleware (HTTP) ─────────────────────────────────────────────────── function requireToken(req, res, next) { const auth = req.headers.authorization ?? ''; if (!auth.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' }); const token = auth.slice(7); const session = sessionByToken(token); if (!session) return res.status(401).json({ error: 'Invalid token' }); req.peerID = peerIDFromToken(session, token); req.session = session; next(); } function requireAdmin(req, res, next) { if (!ADMIN_TOKEN) { return res.status(503).json({ error: 'Admin cleanup is not configured' }); } const auth = req.headers.authorization ?? ''; if (auth !== `Bearer ${ADMIN_TOKEN}`) { return res.status(401).json({ error: 'Unauthorized' }); } next(); } // ─── Pairing routes ─────────────────────────────────────────────────────────── app.post('/pairing/create', (req, res) => { const name = req.body?.displayName?.trim(); if (!name) return res.status(400).json({ error: 'displayName is required' }); let code, tries = 0; do { code = generateCode(); if (++tries > 200) return res.status(503).json({ error: 'Please try again' }); } while (pairingSessions.has(code)); const session = { code, creatorID: randomUUID(), creatorName: name, creatorToken: generateToken(), joinerID: null, joinerName: null, joinerToken: null, expiresAt: Date.now() + PAIRING_TTL_MS, }; pairingSessions.set(code, session); persistSession(session); res.json({ code, token: session.creatorToken, peerID: session.creatorID, expiresAt: new Date(session.expiresAt).toISOString(), }); }); app.post('/pairing/join', (req, res) => { const code = req.body?.code?.trim().toUpperCase(); const name = req.body?.displayName?.trim(); if (!code || !name) return res.status(400).json({ error: 'code and displayName are required' }); const session = pairingSessions.get(code); if (!session || (!session.joinerID && Date.now() > session.expiresAt)) { pairingSessions.delete(code); deleteSession(code); return res.status(404).json({ error: 'Code not found or expired' }); } if (session.joinerID) return res.status(409).json({ error: 'Code already used' }); session.joinerID = randomUUID(); session.joinerName = name; session.joinerToken = generateToken(); delete session.expiresAt; // paired sessions never expire persistSession(session); res.json({ token: session.joinerToken, peerID: session.joinerID, peer: { id: session.creatorID, displayName: session.creatorName }, }); }); // ─── File routes ────────────────────────────────────────────────────────────── app.post('/files', requireToken, upload.single('file'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file provided' }); const fileID = req.file.filename; fileRegistry.set(fileID, { diskPath: req.file.path, originalName: req.file.originalname, size: req.file.size, }); res.json({ id: fileID, name: req.file.originalname, size: req.file.size }); }); app.get('/files/:id', requireToken, (req, res) => { const meta = fileRegistry.get(req.params.id); if (!meta) return res.status(404).json({ error: 'File not found' }); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(meta.originalName)}"`); res.sendFile(meta.diskPath); }); // ─── Admin routes ─────────────────────────────────────────────────────────── app.post('/admin/cleanup', requireAdmin, (req, res) => { const requestedDays = req.body?.days; const days = requestedDays === undefined ? FILE_TTL_DAYS : Number(requestedDays); if (!Number.isInteger(days) || days < 1 || days > 3650) { return res.status(400).json({ error: 'days must be an integer between 1 and 3650' }); } const result = cleanupFilesOlderThan(days); logCleanupResult(result, 'manual'); res.json(result); }); // ─── Health check ───────────────────────────────────────────────────────────── app.get('/health', (_req, res) => res.json({ ok: true, connections: connections.size, queued: [...offlineQueues.values()].reduce((sum, q) => sum + q.length, 0), })); // ─── WebSocket ──────────────────────────────────────────────────────────────── const server = createServer(app); const wss = new WebSocketServer({ server, path: '/ws' }); wss.on('connection', (ws) => { let peerID = null; let peerName = null; let session = null; let authed = false; const authTimeout = setTimeout(() => { if (!authed) ws.close(1008, 'Auth timeout'); }, 10_000); ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw.toString()); } catch { return ws.close(1003, 'Invalid JSON'); } // ─── Auth ───────────────────────────────────────────────────────────────── if (!authed) { if (msg.type !== 'auth' || !msg.token) { return ws.close(1008, 'Send { type: "auth", token: "..." } first'); } session = sessionByToken(msg.token); if (!session) return ws.close(1008, 'Invalid token'); clearTimeout(authTimeout); authed = true; peerID = peerIDFromToken(session, msg.token); peerName = session.creatorToken === msg.token ? session.creatorName : session.joinerName; connections.set(peerID, { ws, name: peerName }); send(ws, { type: 'auth.ok', peerID }); console.log(`[ws] connected: ${peerName} (${peerID})`); // Notify the partner that this peer is now online const partnerIDOnAuth = peerPartnerID(session, peerID); const partnerConnOnAuth = connections.get(partnerIDOnAuth); if (partnerConnOnAuth?.ws.readyState === 1) { send(partnerConnOnAuth.ws, { type: 'peer.joined', peerID, peerName }); } // Deliver messages that arrived while this peer was offline flushQueue(peerID, ws); return; } // ─── Chat message ───────────────────────────────────────────────────────── if (msg.type === 'chat.message') { const partnerID = peerPartnerID(session, peerID); const messageID = msg.id ?? randomUUID(); const outgoing = { ...msg, id: messageID, from: peerID, fromName: peerName }; const target = connections.get(partnerID); if (target?.ws.readyState === 1) { send(target.ws, outgoing); send(ws, { type: 'delivered', id: messageID }); } else { enqueue(partnerID, messageID, outgoing); send(ws, { type: 'queued', id: messageID }); } return; } // ─── Read receipt ───────────────────────────────────────────────────────── // Client sends: { type: "read", messageIDs: ["id1", "id2", ...] } // Server forwards to the sender of those messages so they see "read" ticks if (msg.type === 'read') { const partnerID = peerPartnerID(session, peerID); const receipt = { type: 'read', messageIDs: msg.messageIDs, by: peerID }; const target = connections.get(partnerID); if (target?.ws.readyState === 1) { send(target.ws, receipt); } else { // Queue the read receipt too — partner deserves to know enqueue(partnerID, `read-${randomUUID()}`, receipt); } return; } // ─── Ping ───────────────────────────────────────────────────────────────── if (msg.type === 'ping') { send(ws, { type: 'pong' }); } }); ws.on('close', () => { if (peerID) { connections.delete(peerID); console.log(`[ws] disconnected: ${peerName} (${peerID})`); } clearTimeout(authTimeout); }); ws.on('error', (err) => { console.error('[ws] error:', err.message); }); }); // ─── Start ──────────────────────────────────────────────────────────────────── server.listen(PORT, () => { console.log(`Fchati Relay listening on port ${PORT}`); });