diff --git a/server/src/routes/connectionRoutes.ts b/server/src/routes/connectionRoutes.ts new file mode 100644 index 0000000..1e06ddf --- /dev/null +++ b/server/src/routes/connectionRoutes.ts @@ -0,0 +1,98 @@ +import { Router } from 'express'; +import { query, validationResult } from 'express-validator'; +import { partyLines } from '../stores/dataStore'; +import { v4 as uuidv4 } from 'uuid'; +import { Client } from "../types/partyLine"; + +const router = Router(); + +// Route to verify if a party line exists before connecting +router.get('/joinPartyLine', [ + query('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name') +], (req: any, res: any) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + try { + const currentPartyLine = req.query.partyLine; + const partyLine = partyLines[currentPartyLine]; + if (!partyLine) { + return res.status(404).send({ status: 'Party line not found' }); + } + res.status(200).send({ status: 'Connection to party line authorized' }); + } catch (error) { + console.error('ERROR: Failed to join party line', error); + res.status(500).send({ status: 'Internal server error' }); + } +}); + +// Route to connect to party line and receive rumors +router.get('/connectPartyLine', [ + query('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name') +], (req: any, res: any) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + try { + const connectedPartyLine = req.query.partyLine; + const partyLine = partyLines[connectedPartyLine]; + if (!partyLine) { + return res.status(404).send({ status: 'Party line not found' }); + } + + // Set headers for Server-Sent Events (SSE) + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const clientId = `${uuidv4()}`; + const client: Client = { clientId, response: res, ipAddress: res.socket.remoteAddress }; + + // Add client to the party line + partyLine.clients.push(client); + partyLine.lastActivity = Date.now(); + + console.log(`JOIN: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`); + + // Send the last event to the client + res.write(`data: ${partyLine.lastEvent}\n\n`); + + // Keep-alive mechanism to keep the connection open + const keepAliveId = setInterval(() => { + res.write(': keep-alive\n\n'); + }, 15000); + + // Function to remove the client from the party line + const removeClient = () => { + clearInterval(keepAliveId); + const index = partyLine.clients.findIndex((client: any) => client.clientId === clientId); + if (index !== -1) { + partyLine.clients.splice(index, 1); + console.log(`REMOVE: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`); + } else { + console.log(`CLIENT_NOT_FOUND: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`); + } + try { + res.write('event: close\ndata: finished\n\n'); + res.end(); + } catch (err) { + console.error(`ERROR: Error sending close event for client ${clientId}:`, err); + } + console.log(`DISCONNECT: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`); + }; + + // Handle client disconnection + req.on('close', removeClient); + req.on('error', (err: any) => { + console.error(`ERROR: Error in client connection in party line ${connectedPartyLine}:`, err); + removeClient(); + }); + } catch (error) { + console.error('ERROR: Failed to connect to party line', error); + res.status(500).send({ status: 'Internal server error' }); + } +}); + +export default router; \ No newline at end of file diff --git a/server/src/routes/managementRoutes.ts b/server/src/routes/managementRoutes.ts new file mode 100644 index 0000000..e372a5a --- /dev/null +++ b/server/src/routes/managementRoutes.ts @@ -0,0 +1,115 @@ +import { Router } from 'express'; +import { body, validationResult } from 'express-validator'; +import { MAX_PARTY_LINES, INITIAL_RUMOR } from '../stores/configStore'; +import { partyLines } from '../stores/dataStore'; +import { broadcast } from '../services/broadcastService'; + +const router = Router(); + +// Route to get all party lines +router.get('/partyLines', (_req: any, res: any) => { + const allPartyLines = Object.keys(partyLines).map(key => ({ + name: key, + ...partyLines[key], + clients: partyLines[key].clients.map(({ response, ...client }) => client) + })); + res.status(200).send(allPartyLines); +}); + +// Route to create a new party line +router.post('/createPartyLine', [ + body('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name') +], (req: any, res: any) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + try { + const { partyLine: currentPartyLine } = req.body; + if (Object.keys(partyLines).length >= MAX_PARTY_LINES) { + return res.status(400).send({ status: 'Maximum number of party lines reached' }); + } + if (partyLines[currentPartyLine]) { + return res.status(400).send({ status: 'Party line already exists' }); + } + + // Create a new party line + partyLines[currentPartyLine] = { + clients: [], + lastEvent: INITIAL_RUMOR, + lastActivity: Date.now() + }; + + console.log(`CREATE: { partyLine: ${currentPartyLine} }`); + res.status(200).send({ status: 'Party line created', currentPartyLine }); + } catch (error) { + console.error('ERROR: Failed to create party line', error); + res.status(500).send({ status: 'Internal server error' }); + } +}); + +// Route to delete a party line +router.delete('/deletePartyLine', [ + body('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name') + ], (req: any, res: any) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + try { + const { partyLine: currentPartyLine } = req.body; + const partyLine = partyLines[currentPartyLine]; + if (!partyLine) { + return res.status(404).send({ status: 'Party line not found' }); + } + + // Broadcast deletion message to all clients + broadcast(currentPartyLine, 'PARTY_LINE_DELETED'); + + // Disconnect all clients + partyLine.clients.forEach((client: any) => { + console.log(`DISCONNECTING: { partyLine: ${currentPartyLine}, clientId: ${client.clientId} }`); + client.response.end(); + }); + + // Delete the party line + delete partyLines[currentPartyLine]; + console.log(`DELETE: { partyLine: ${currentPartyLine} }`); + res.status(200).send({ status: 'Party line deleted', currentPartyLine }); + } catch (error) { + console.error('ERROR: Failed to delete party line', error); + res.status(500).send({ status: 'Internal server error' }); + } + }); + + // Route to send an event to a party line +router.post('/rumor', [ + body('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name'), + body('rumor').isString().trim().escape().notEmpty().withMessage('Invalid rumor') + ], (req: any, res: any) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + try { + const { partyLine: connectedPartyLine, rumor } = req.body; + const partyLine = partyLines[connectedPartyLine]; + if (!partyLine) { + return res.status(404).send({ status: 'Party line not found' }); + } + + console.log(`RECEIVE: { partyLine: ${connectedPartyLine}, rumor: ${rumor} }`); + + // Update the last event and broadcast it to all clients + partyLine.lastEvent = rumor; + partyLine.clients.forEach((client: any) => { + client.response.write(`data: ${rumor}\n\n`); + }); + res.status(200).send({ status: 'Rumor broadcast' }); + } catch (error) { + console.error('ERROR: Failed to send event', error); + res.status(500).send({ status: 'Internal server error' }); + } + }); + + export default router; \ No newline at end of file diff --git a/server/src/routes/navigationRoutes.ts b/server/src/routes/navigationRoutes.ts new file mode 100644 index 0000000..089319b --- /dev/null +++ b/server/src/routes/navigationRoutes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import path from 'path'; + +const router = Router(); + +// Navigate to the admin route in the client +router.get('/admin', (_req: any, res: any) => { + res.sendFile(path.resolve(__dirname, '../../static', 'index.html')); +}); + +export default router; \ No newline at end of file diff --git a/server/src/server.ts b/server/src/server.ts index 9611d95..4014314 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,21 +1,21 @@ import express from 'express'; -import { body, query, validationResult } from 'express-validator'; import rateLimit from 'express-rate-limit'; import cors from 'cors'; import bodyParser from 'body-parser'; -import { v4 as uuidv4 } from 'uuid'; -import dotenv from 'dotenv'; import helmet from 'helmet'; import path from 'path'; - -dotenv.config(); +import { partyLines } from './stores/dataStore'; +import { ENVIRONMENT, PORT, CLIENT_URL, DOCKER } from './stores/configStore'; +import { broadcast } from './services/broadcastService'; +import connectionRoutes from './routes/connectionRoutes'; +import managementRoutes from './routes/managementRoutes'; +import navigationRoutes from './routes/navigationRoutes'; const app = express(); -const PORT = Number(process.env.PORT); // CORS configuration options const corsOptions = { - origin: process.env.CLIENT_URL, + origin: CLIENT_URL, optionsSuccessStatus: 200, methods: ['GET', 'POST', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'] @@ -38,7 +38,7 @@ app.use(limiter); app.use('/', express.static(path.join(__dirname, '../static'))); // Force HTTPS redirection and CSP in production -if (process.env.ENVIRONMENT === 'production') { +if (ENVIRONMENT === 'production') { app.use((req: any, res: any, next: any) => { if (req.headers['x-forwarded-proto'] !== 'https') { return res.redirect(`https://${req.headers.host}${req.url}`); @@ -57,7 +57,7 @@ if (process.env.ENVIRONMENT === 'production') { }) ); } else { - // Disable https preference for non-prod + // Disable CSP for non-prod app.use( helmet({ contentSecurityPolicy: false, @@ -65,131 +65,17 @@ if (process.env.ENVIRONMENT === 'production') { ); } -// Navigate to the admin route in the client -app.get('/admin', (_req: any, res: any) => { - res.sendFile(path.resolve(__dirname, '../static', 'index.html')); -}); +// Routes +app.use(navigationRoutes); +app.use(managementRoutes); +app.use(connectionRoutes); -// Maximum number of party lines allowed -const MAX_PARTY_LINES = Number(process.env.MAX_PARTY_LINES); - -// In-memory storage for party lines -const partyLines = {}; - -// Function to broadcast a rumor to all clients at a party line -const broadcast = (currentPartyLine: string, rumor: string) => { - const partyLine = partyLines[currentPartyLine]; - if (partyLine) { - console.log(`BROADCAST: { partyLine: ${currentPartyLine}, rumor: ${rumor} }`); - partyLine.clients.forEach((client: any) => { - if (client.response && typeof client.response.write === 'function') { - try { - client.response.write(`data: ${rumor}\n\n`); - } catch (err) { - console.error(`ERROR: Failed to write to client ${client.clientId}`, err); - } - } else { - console.error(`ERROR: Response object is invalid for client ${client.clientId}`); - } - }); - } -}; - -// Route to verify if a party line exists before connecting -app.get('/joinPartyLine', [ - query('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name') -], (req: any, res: any) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - try { - const currentPartyLine = req.query.partyLine; - const partyLine = partyLines[currentPartyLine]; - if (!partyLine) { - return res.status(404).send({ status: 'Party line not found' }); - } - res.status(200).send({ status: 'Connection to party line authorized' }); - } catch (error) { - console.error('ERROR: Failed to join party line', error); - res.status(500).send({ status: 'Internal server error' }); - } -}); - -// Route to connect to party line and receive rumors -app.get('/connectPartyLine', [ - query('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name') -], (req: any, res: any) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - try { - const connectedPartyLine = req.query.partyLine; - const partyLine = partyLines[connectedPartyLine]; - if (!partyLine) { - return res.status(404).send({ status: 'Party line not found' }); - } - - // Set headers for Server-Sent Events (SSE) - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - - const clientId = `${uuidv4()}`; - const client = { clientId, response: res }; - - // Add client to the party line - partyLine.clients.push(client); - partyLine.lastActivity = Date.now(); - - console.log(`JOIN: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`); - - // Send the last event to the client - res.write(`data: ${partyLine.lastEvent}\n\n`); - - // Keep-alive mechanism to keep the connection open - const keepAliveId = setInterval(() => { - res.write(': keep-alive\n\n'); - }, 15000); - - // Function to remove the client from the party line - const removeClient = () => { - clearInterval(keepAliveId); - const index = partyLine.clients.findIndex((client: any) => client.clientId === clientId); - if (index !== -1) { - partyLine.clients.splice(index, 1); - console.log(`REMOVE: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`); - } else { - console.log(`CLIENT_NOT_FOUND: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`); - } - try { - res.write('event: close\ndata: finished\n\n'); - res.end(); - } catch (err) { - console.error(`ERROR: Error sending close event for client ${clientId}:`, err); - } - console.log(`DISCONNECT: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`); - }; - - // Handle client disconnection - req.on('close', removeClient); - req.on('error', (err: any) => { - console.error(`ERROR: Error in client connection in party line ${connectedPartyLine}:`, err); - removeClient(); - }); - } catch (error) { - console.error('ERROR: Failed to connect to party line', error); - res.status(500).send({ status: 'Internal server error' }); - } -}); - -// Periodically resend the last event and clean up inactive party lines +// Periodically resend the last rumor and clean up inactive party lines setInterval(() => { const now = Date.now(); Object.keys(partyLines).forEach((currentPartyLine) => { const partyLine = partyLines[currentPartyLine]; - if (partyLine.lastEvent) { + if (partyLine.lastEvent && partyLine.clients.length > 0) { broadcast(currentPartyLine, partyLine.lastEvent); } if (now - partyLine.lastActivity > 3600000) { // 1 hour in milliseconds @@ -199,113 +85,11 @@ setInterval(() => { }); }, 30000); -// Route to create a new party line -app.post('/createPartyLine', [ - body('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name') -], (req: any, res: any) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - try { - const { partyLine: currentPartyLine } = req.body; - if (Object.keys(partyLines).length >= MAX_PARTY_LINES) { - return res.status(400).send({ status: 'Maximum number of party lines reached' }); - } - if (partyLines[currentPartyLine]) { - return res.status(400).send({ status: 'Party line already exists' }); - } - - // Create a new party line - partyLines[currentPartyLine] = { - clients: [], - lastEvent: process.env.INITIAL_RUMOR, - lastActivity: Date.now() - }; - - console.log(`CREATE: { partyLine: ${currentPartyLine} }`); - res.status(200).send({ status: 'Party line created', currentPartyLine }); - } catch (error) { - console.error('ERROR: Failed to create party line', error); - res.status(500).send({ status: 'Internal server error' }); - } -}); - -// Route to send an event to a party line -app.post('/rumor', [ - body('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name'), - body('rumor').isString().trim().escape().notEmpty().withMessage('Invalid rumor') -], (req: any, res: any) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - try { - const { partyLine: connectedPartyLine, rumor } = req.body; - const partyLine = partyLines[connectedPartyLine]; - if (!partyLine) { - return res.status(404).send({ status: 'Party line not found' }); - } - - console.log(`RECEIVE: { partyLine: ${connectedPartyLine}, rumor: ${rumor} }`); - - // Update the last event and broadcast it to all clients - partyLine.lastEvent = rumor; - partyLine.clients.forEach((client: any) => { - client.response.write(`data: ${rumor}\n\n`); - }); - res.status(200).send({ status: 'Rumor broadcast' }); - } catch (error) { - console.error('ERROR: Failed to send event', error); - res.status(500).send({ status: 'Internal server error' }); - } -}); - -// Route to get all party lines -app.get('/partyLines', (_req: any, res: any) => { - const allPartyLines = Object.keys(partyLines); - res.status(200).send({ allPartyLines }); -}); - -// Route to delete a party line -app.delete('/deletePartyLine', [ - body('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name') -], (req: any, res: any) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - try { - const { partyLine: currentPartyLine } = req.body; - const partyLine = partyLines[currentPartyLine]; - if (!partyLine) { - return res.status(404).send({ status: 'Party line not found' }); - } - - // Broadcast deletion message to all clients - broadcast(currentPartyLine, 'PARTY_LINE_DELETED'); - - // Disconnect all clients - partyLine.clients.forEach((client: any) => { - console.log(`DISCONNECTING: { partyLine: ${currentPartyLine}, clientId: ${client.clientId} }`); - client.response.end(); - }); - - // Delete the party line - delete partyLines[currentPartyLine]; - console.log(`DELETE: { partyLine: ${currentPartyLine} }`); - res.status(200).send({ status: 'Party line deleted', currentPartyLine }); - } catch (error) { - console.error('ERROR: Failed to delete party line', error); - res.status(500).send({ status: 'Internal server error' }); - } -}); - // Start the server app.listen(PORT, () => { console.log(`Rumor Party Line`); - console.log(`Internally running on http://localhost:${PORT} in ${process.env.ENVIRONMENT} mode.`); - if (process.env.DOCKER) { + console.log(`Internally running on http://localhost:${PORT} in ${ENVIRONMENT} mode.`); + if (DOCKER) { console.log(`You're running on Docker!\n↳ Check your external port in the compose file to avoid confusion!`); } }); \ No newline at end of file diff --git a/server/src/services/broadcastService.ts b/server/src/services/broadcastService.ts new file mode 100644 index 0000000..d2248b4 --- /dev/null +++ b/server/src/services/broadcastService.ts @@ -0,0 +1,20 @@ +import { partyLines } from '../stores/dataStore'; + +// Function to broadcast a rumor to all clients at a party line +export const broadcast = (currentPartyLine: string, rumor: string) => { + const partyLine = partyLines[currentPartyLine]; + if (partyLine) { + console.log(`BROADCAST: { partyLine: ${currentPartyLine}, rumor: ${rumor} }`); + partyLine.clients.forEach((client: any) => { + if (client.response && typeof client.response.write === 'function') { + try { + client.response.write(`data: ${rumor}\n\n`); + } catch (err) { + console.error(`ERROR: Failed to write to client ${client.clientId}`, err); + } + } else { + console.error(`ERROR: Response object is invalid for client ${client.clientId}`); + } + }); + } +}; \ No newline at end of file diff --git a/server/src/stores/configStore.ts b/server/src/stores/configStore.ts new file mode 100644 index 0000000..016654a --- /dev/null +++ b/server/src/stores/configStore.ts @@ -0,0 +1,9 @@ +import dotenv from 'dotenv'; +dotenv.config(); + +export const ENVIRONMENT = process.env.ENVIRONMENT || 'development'; +export const PORT = Number(process.env.PORT) || 3000; +export const CLIENT_URL = process.env.CLIENT_URL || 'http://localhost:3000'; +export const MAX_PARTY_LINES = Number(process.env.MAX_PARTY_LINES) || 1; +export const INITIAL_RUMOR = process.env.INITIAL_RUMOR || 'Lorem Ipsum.'; +export const DOCKER = Boolean(process.env.DOCKER); \ No newline at end of file diff --git a/server/src/stores/dataStore.ts b/server/src/stores/dataStore.ts new file mode 100644 index 0000000..621630e --- /dev/null +++ b/server/src/stores/dataStore.ts @@ -0,0 +1,3 @@ +import { PartyLine } from "../types/partyLine"; + +export const partyLines: PartyLine[] = []; \ No newline at end of file diff --git a/server/src/types/partyLine.ts b/server/src/types/partyLine.ts new file mode 100644 index 0000000..1ffdf66 --- /dev/null +++ b/server/src/types/partyLine.ts @@ -0,0 +1,11 @@ +export type PartyLine = { + clients: Client[]; + lastActivity: number; + lastEvent: string; +} + +export type Client = { + clientId: string; + response: any; + ipAddress: string; +}