/*
Author: Twily 2025
Website: twily.info
*/
//require('dotenv').config();
//const SERVER_PORT = process.env.PORT || 3000;
//const CLIENT_URL = process.env.CLIENT_URL || 'http://localhost:5173';
const SERVER_PORT = 3000;
//const CLIENT_URL = '/'; // prod
const CLIENT_URL = 'http://10.0.0.64:5173'; // dev
const express = require('express');
const cors = require('cors');
const app = express();
const http = require('http').createServer(app);
const io = require('socket.io')(http, { cors: { origin: CLIENT_URL, credentials: true } });
const fs = require('fs').promises;
const path = require('path');
const multer = require('multer');
const DB_FILE = path.join(__dirname, 'sessions.log');
const NAME_FILE = path.join(__dirname, 'name.log');
const WORD_FILE = path.join(__dirname, 'word.log');
const upload = multer({ dest: 'uploads/' });
let sessionsCache = [];
const activeDownloads = {}; // { code: { fileName: count } }
app.use(cors({
origin: CLIENT_URL,
methods: ["GET", "POST"],
credentials: true
}));
app.use(express.static('public'));
const capitalizeFirstLetter = (str) => {
if (typeof str !== 'string' || str.length === 0) {
return str; // Handle non-string input or empty strings
}
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}
const loadWords = async (filePath) => {
try {
const data = await fs.readFile(filePath, 'utf8');
return data.split('\n').filter(line => line.trim());
} catch (e) {
console.error(`Error reading ${filePath}:`, e);
return [];
}
}
const generateCode = async () => {
const names = await loadWords(NAME_FILE);
const words = await loadWords(WORD_FILE);
if (names.length === 0 || words.length === 0) {
throw new Error('Word lists are empty or missing');
}
const name = capitalizeFirstLetter(names[Math.floor(Math.random() * names.length)]);
const word = capitalizeFirstLetter(words[Math.floor(Math.random() * words.length)]);
const order = Math.random() < 0.5;
return order ? `${name} ${word}` : `${word} ${name}`;
}
app.get('/api/generate-code', async (req, res) => {
try {
const code = await generateCode();
res.json({ code });
} catch (e) {
console.error('Error generating code:', e);
res.status(500).json({ error: 'Failed to generate code' });
}
});
const loadSessions = async () => {
try {
const data = await fs.readFile(DB_FILE, 'utf8');
sessionsCache = data.split('\n').filter(line => line).map(line => JSON.parse(line));
} catch (e) {
console.error('Error loading sessions:', e);
}
}
const saveSession = async (session) => {
try {
sessionsCache.push(session);
await fs.appendFile(DB_FILE, JSON.stringify(session) + '\n');
} catch (e) {
console.error('Error saving session:', e);
}
}
const deleteSession = async (code) => {
try {
const session = sessionsCache.find((s) => s.code === code);
if (session) {
console.log(`Deleting files for room ${code}: ${session.files.length} files`);
for (const file of session.files) {
try {
const filePath = path.join(__dirname, 'uploads', path.basename(file.path || ''));
await fs.unlink(filePath);
console.log(`Deleted file: ${filePath}`);
} catch (e) {
console.error(`Error deleting file ${file.path || 'unknown'}:`, e);
}
}
} else {
console.log(`No session found for room ${code}`);
}
sessionsCache = sessionsCache.filter((s) => s.code !== code);
await fs.writeFile(DB_FILE, sessionsCache.map((s) => JSON.stringify(s) + '\n').join(''));
delete activeDownloads[code];
console.log(`Deleted session for room ${code}`);
} catch (e) {
console.error('Error deleting session:', e);
}
}
// Create or join session
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
socket.on('create', async (code, callback) => {
if (!code || sessionsCache.some((s) => s.code === code)) {
socket.emit('error', 'Room already exists or invalid code');
return;
}
const session = { code, peers: [socket.id], files: [] };
await saveSession(session);
socket.join(code);
socket.emit('created', code);
if (callback) callback({ success: true, message: `Created room ${code}` });
console.log(`Room created: ${code}, peer: ${socket.id}`);
});
socket.on('join', async (code, callback) => {
if (!code) {
socket.emit('error', 'Invalid room code');
if (callback) callback({ error: 'No room code provided' });
return;
}
const session = sessionsCache.find((s) => s.code === code);
if (!session) {
socket.emit('error', 'Room does not exist');
if (callback) callback({ error: `Room ${code} not found` });
return;
}
if (session.peers.includes(socket.id)) {
socket.emit('error', `Already in room ${code}`);
if (callback) callback({ error: `Already in room ${code}` });
return;
}
session.peers.push(socket.id);
socket.join(code);
socket.to(code).emit('peer-joined', { peerId: socket.id });
if(session.useFallback) {
socket.emit('files', session.files);
socket.emit('room-status', { useFallback: session.useFallback || false, textHistory: session.textHistory });
} else {
console.log('skipping room-status for webrtc file and text~');
// file list and text history is shared over webrtc
}
if (callback) callback({ success: true, message: `Joined room ${code}` });
console.log(`User ${socket.id} joined room ${code}, useFallback=${session.useFallback || false}`);
});
socket.on('leave', async (code, callback) => {
if (!code) {
if (callback) callback({ error: 'No room code provided' });
return;
}
const session = sessionsCache.find((s) => s.code === code);
if (session) {
const wasCreator = session.peers[0] === socket.id;
session.peers = session.peers.filter((id) => id !== socket.id);
if (session.peers.length === 0) {
sessionsCache = sessionsCache.filter((s) => s.code !== code);
await deleteSession(code);
console.log(`Deleted room ${code} (no peers left)`);
if (callback) callback({ success: true, message: `Room ${code} deleted` });
} else if (wasCreator) {
sessionsCache = sessionsCache.filter((s) => s.code !== code);
await deleteSession(code);
io.to(code).emit('error', 'Room closed by creator');
console.log(`Closed room ${code} (creator left)`);
if (callback) callback({ success: true, message: `Room ${code} closed by creator` });
} else {
socket.to(code).emit('peer-left', { peerId: socket.id });
console.log(`User ${socket.id} left room ${code}, peers: ${session.peers}`);
if (callback) callback({ success: true, message: `Left room ${code}` });
}
} else {
if (callback) callback({ error: `Room ${code} not found` });
}
});
// alt variant close when last leave (not updated)
//socket.on('leave', async (code) => {
// if (!code) return;
// const session = sessionsCache.find((s) => s.code === code);
// if (session) {
// session.peers = session.peers.filter((id) => id !== socket.id);
// if (session.peers.length === 0) {
// await deleteSession(code);
// } else {
// await fs.writeFile(DB_FILE, sessionsCache.map((s) => JSON.stringify(s) + '\n').join(''));
// socket.to(code).emit('peer-left', { peerId: socket.id });
// }
// }
// socket.leave(code);
// console.log(`User ${socket.id} left room ${code}`);
//});
socket.on('offer', ({ code, offer, to, from }) => {
if (!to || !code) return;
socket.to(to).emit('offer', { code, offer, to, from });
console.log(`Forwarded offer from ${from} to ${to}`);
});
socket.on('answer', ({ code, answer, to, from }) => {
if (!to || !code) return;
socket.to(to).emit('answer', { code, answer, to, from });
console.log(`Forwarded answer from ${from} to ${to}`);
});
socket.on('ice-candidate', ({ code, candidate, to, from }) => {
if (!to || !code) return;
socket.to(to).emit('ice-candidate', { code, candidate, to, from });
console.log(`Forwarded ICE candidate from ${socket.id} to ${to}`);
});
socket.on('text', async ({ code, text }) => {
if (!code) return;
const session = sessionsCache.find((s) => s.code === code);
if (session) {
if (!session.textHistory) session.textHistory = [];
session.textHistory.push(text);
await fs.writeFile(DB_FILE, sessionsCache.map((s) => JSON.stringify(s) + '\n').join(''));
socket.to(code).emit('text', { text });
socket.emit('text', { text }); // Echo back to sender
console.log(`Forwarded text to room ${code}: ${text}`);
}
});
socket.on('fallback', async ({ code }) => {
const session = sessionsCache.find((s) => s.code === code);
if (session) {
session.useFallback = true;
await fs.writeFile(DB_FILE, sessionsCache.map((s) => JSON.stringify(s) + '\n').join(''));
socket.to(code).emit('fallback');
console.log(`Room ${code} switched to fallback mode`);
}
});
socket.on('file-list', ({ code, files }) => {
if (!code) return;
socket.to(code).emit('file-list', { files });
console.log(`Forwarded file list to room ${code}:`, files);
});
socket.on('request-file', ({ code, fileName, to }) => {
if (!to || !code || !fileName) return;
socket.to(to).emit('request-file', { fileName, from: socket.id });
console.log(`Forwarded file request for ${fileName} from ${socket.id} to ${to}`);
});
socket.on('file-data', ({ fileName, data, to }) => {
if (!to || !fileName) return;
socket.to(to).emit('file-data', { fileName, data });
console.log(`Forwarded file data for ${fileName} from ${socket.id} to ${to}`);
});
// Server-side
socket.on('check-room-status', async (code) => {
const session = sessionsCache.find((s) => s.code === code);
if (!session) {
socket.emit('room-status-check', { exists: false });
} else {
const isInRoom = session.creator === socket.id || session.peers.includes(socket.id);
socket.emit('room-status-check', { exists: true, isInRoom });
}
});
socket.on('download-start-fallback', ({ code, fileName }) => {
if (!activeDownloads[code]) activeDownloads[code] = {};
activeDownloads[code][fileName] = (activeDownloads[code][fileName] || 0) + 1;
io.to(code).emit('update-count-fallback', { fileName, count: activeDownloads[code][fileName] });
console.log(`Fallback download started for ${fileName} in room ${code}, count: ${activeDownloads[code][fileName]}`);
});
socket.on('download-end-fallback', ({ code, fileName }) => {
if (activeDownloads[code] && activeDownloads[code][fileName]) {
activeDownloads[code][fileName]--;
if (activeDownloads[code][fileName] <= 0) delete activeDownloads[code][fileName];
io.to(code).emit('update-count-fallback', { fileName, count: activeDownloads[code][fileName] || 0 });
console.log(`Fallback download ended for ${fileName} in room ${code}, count: ${activeDownloads[code][fileName] || 0}`);
}
});
// Room closes if/when creator leaves
socket.on('disconnect', async () => {
console.log('User disconnected:', socket.id);
const session = sessionsCache.find((s) => s.peers.includes(socket.id));
if (session) {
const wasCreator = session.peers[0] === socket.id;
session.peers = session.peers.filter((id) => id !== socket.id);
if (session.peers.length === 0) {
sessionsCache = sessionsCache.filter((s) => s.code !== session.code);
await deleteSession(session.code);
console.log(`Deleted room ${session.code} (no peers left)`);
} else if (wasCreator) {
sessionsCache = sessionsCache.filter((s) => s.code !== session.code);
await deleteSession(session.code);
io.to(session.code).emit('error', 'Room closed by creator');
console.log(`Closed room ${session.code} (creator disconnected)`);
} else {
//await updateSession(session);
io.to(session.code).emit('peer-left', { peerId: socket.id });
console.log(`User ${socket.id} disconnected from room ${session.code}, peers: ${session.peers}`);
}
}
});
// Alternative keep room alive until last leave?
//socket.on('disconnect', async () => {
// console.log(`User disconnected: ${socket.id}`);
// const session = sessionsCache.find((s) => s.peers.includes(socket.id));
// if (session) {
// session.peers = session.peers.filter((id) => id !== socket.id);
// if (session.peers.length === 0) {
// await deleteSession(session.code);
// } else {
// await fs.writeFile(DB_FILE, sessionsCache.map((s) => JSON.stringify(s) + '\n').join(''));
// socket.to(session.code).emit('peer-left', { peerId: socket.id });
// }
// }
//});
});
// File upload for fallback
app.post('/api/upload/:code', upload.array('files', 10), async (req, res) => {
const { code } = req.params;
const session = sessionsCache.find((s) => s.code === code);
if (!session) {
res.status(404).send('Room not found');
return;
}
const files = req.files;
if (!files || files.length === 0) {
res.status(400).send('No files uploaded');
return;
}
const uploaderId = req.headers['x-socket-id'];
const fileList = files.map(file => ({
name: file.originalname,
path: file.path,
uploaderId,
size: file.size,
}));
session.files.push(...fileList);
await fs.writeFile(DB_FILE, sessionsCache.map((s) => JSON.stringify(s) + '\n').join(''));
io.to(code).emit('file-list', { files: fileList });
res.status(200).send('Files uploaded');
});
http.listen(SERVER_PORT, async () => {
await fs.mkdir('uploads', { recursive: true });
await loadSessions();
console.log(`Server running on port ${SERVER_PORT}`);
});
Top