437 lines
18 KiB
JavaScript
437 lines
18 KiB
JavaScript
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import http from 'node:http'
|
|
import { fileURLToPath } from 'node:url'
|
|
import express from 'express'
|
|
import compression from 'compression'
|
|
import { JSDOM } from 'jsdom';
|
|
import DOMPurify from 'dompurify';
|
|
import { Server as SocketIOServer } from 'socket.io'
|
|
import { server as serverConfig, headerBarUrl, logoUrl, database } from './config.js'
|
|
import * as roomsManager from './lib/rooms.js' // Assuming rooms.js uses ES module exports
|
|
|
|
// Database - This will be initialized later. Needs to export `db` as an ES module.
|
|
// Example: export let db; export function initializeDb(callback) { db = new TheActualDb(callback); }
|
|
// For now, assuming it exports an object with a `db` property that gets assigned.
|
|
import * as dbModule from './lib/data/redis.js'
|
|
|
|
// ESM equivalent of __dirname and __filename
|
|
const __filename = fileURLToPath(import.meta.url)
|
|
const __dirname = path.dirname(__filename)
|
|
|
|
// Helper for consistent logging
|
|
const log = (level, ...args) => {
|
|
const timestamp = new Date().toISOString()
|
|
if (level === 'error') {
|
|
console.error(`[${timestamp}] ERROR:`, ...args)
|
|
} else if (level === 'warn') {
|
|
console.warn(`[${timestamp}] WARN:`, ...args)
|
|
} else {
|
|
console.log(`[${timestamp}] INFO:`, ...args)
|
|
}
|
|
}
|
|
|
|
// --- Global Error Handlers ---
|
|
process.on('uncaughtException', (err, origin) => {
|
|
const message = `UNCAUGHT EXCEPTION: ${err.stack || err}\nException origin: ${origin}`
|
|
log('error', message)
|
|
try {
|
|
fs.writeFileSync(path.join(__dirname, 'crash_uncaught.log'), `${message}\nPID: ${process.pid}\n`)
|
|
} catch (e) {
|
|
log('error', 'Failed to write crash_uncaught.log:', e)
|
|
}
|
|
process.exit(1)
|
|
})
|
|
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|
const reasonStack = reason instanceof Error && reason.stack ? reason.stack : reason
|
|
const message = `UNHANDLED REJECTION: ${reasonStack}\nAt promise: ${JSON.stringify(promise)}`
|
|
log('error', message)
|
|
try {
|
|
fs.writeFileSync(path.join(__dirname, 'crash_unhandled.log'), `${message}\nPID: ${process.pid}\n`)
|
|
} catch (e) {
|
|
log('error', 'Failed to write crash_unhandled.log:', e)
|
|
}
|
|
process.exit(1)
|
|
})
|
|
|
|
let dbInstance // Will hold the initialized DB instance
|
|
|
|
// --- Globals (Minimize these if possible) ---
|
|
const sidsToUsernames = new Map() // Using a Map is cleaner for sids_to_user_names
|
|
|
|
async function main() {
|
|
try {
|
|
log('info', 'Inside main function.')
|
|
|
|
const app = express()
|
|
const router = express.Router()
|
|
|
|
app.use(compression())
|
|
|
|
app.set('views', path.join(__dirname, 'views'))
|
|
app.set('view engine', 'pug')
|
|
app.use(serverConfig.baseurl, router)
|
|
|
|
router.use(express.static(path.join(__dirname, 'node_modules')))
|
|
router.use(express.static(path.join(__dirname, 'client')))
|
|
|
|
const httpServer = http.createServer(app)
|
|
const io = new SocketIOServer(httpServer, {
|
|
path: serverConfig.baseurl === '/' ? '/socket.io' : `${serverConfig.baseurl}/socket.io`,
|
|
cookie: false
|
|
})
|
|
const window = new JSDOM('').window;
|
|
const purify = DOMPurify(window);
|
|
// --- Database Initialization ---
|
|
// This assumes your db module exports a class or function that can be instantiated
|
|
// and takes a callback for when it's ready.
|
|
// Modify this according to how your actual db module works.
|
|
log('info', `Initializing database type: ${database.type}`)
|
|
dbInstance = new dbModule.db(() => { // This `db` should be the actual constructor/class from your module
|
|
log('info', 'Database initialized.')
|
|
cleanAndInitializeDemoRoom()
|
|
startServer()
|
|
})
|
|
|
|
function startServer() {
|
|
app.use((err, req, res, next) => {
|
|
log('error', `EXPRESS GLOBAL ERROR HANDLER triggered for ${req.originalUrl}:`, err.stack || err)
|
|
if (!res.headersSent) {
|
|
res.status(500).send('Something broke on the server!')
|
|
} else {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
httpServer.listen(serverConfig.port, () => {
|
|
log('info', `Web server available on http://127.0.0.1:${serverConfig.port}`)
|
|
log('info', `Base URL: http://127.0.0.1:${serverConfig.port}${serverConfig.baseurl}`)
|
|
})
|
|
}
|
|
|
|
// --- HTTP Routes (defined on the router) ---
|
|
router.get('/', (req, res) => {
|
|
try {
|
|
log('info', `Route / requested by ${req.ip}`)
|
|
const fullUrl = `${req.protocol}://${req.get('host')}${req.baseUrl}`
|
|
const connectedSockets = io.sockets.sockets
|
|
const clientsCount = connectedSockets.size
|
|
log('info', `Socket.IO 1.7.4 clientsCount: ${clientsCount}`)
|
|
log('info', {
|
|
url: fullUrl,
|
|
headerBarUrl,
|
|
logoUrl,
|
|
connected: clientsCount,
|
|
home: true
|
|
})
|
|
res.render('home', {
|
|
url: fullUrl,
|
|
headerBarUrl,
|
|
logoUrl,
|
|
connected: clientsCount,
|
|
home: true
|
|
})
|
|
} catch (renderError) {
|
|
log('error', 'Error rendering / route:', renderError)
|
|
if (!res.headersSent) {
|
|
res.status(500).send('Error rendering page.')
|
|
}
|
|
}
|
|
})
|
|
|
|
router.get('/demo', (req, res) => {
|
|
try {
|
|
log('info', `Route /demo requested by ${req.ip}`)
|
|
const fullUrl = `${req.protocol}://${req.get('host')}${req.baseUrl}`
|
|
res.render('index', { // Assumes index.jade
|
|
pageTitle: 'Memo - demo',
|
|
headerBarUrl,
|
|
logoUrl,
|
|
url: fullUrl,
|
|
demo: true
|
|
})
|
|
} catch (renderError) {
|
|
log('error', 'Error rendering /demo route:', renderError)
|
|
if (!res.headersSent) {
|
|
res.status(500).send('Error rendering page.')
|
|
}
|
|
}
|
|
})
|
|
|
|
router.get('/:id', (req, res) => {
|
|
try {
|
|
const { id } = req.params
|
|
log('info', `Route /:id (${id}) requested by ${req.ip}`)
|
|
const fullUrl = `${req.protocol}://${req.get('host')}${req.baseUrl}`
|
|
res.render('index', { // Assumes index.jade
|
|
pageTitle: `Memo - ${id}`,
|
|
headerBarUrl,
|
|
logoUrl,
|
|
url: fullUrl
|
|
})
|
|
} catch (renderError) {
|
|
log('error', `Error rendering /:id (${req.params.id}) route:`, renderError)
|
|
if (!res.headersSent) {
|
|
res.status(500).send('Error rendering page.')
|
|
}
|
|
}
|
|
})
|
|
|
|
router.get('/stats', (req, res) => {
|
|
log('info', 'TODO: /stats route hit')
|
|
res.status(501).send('Stats page not implemented yet.')
|
|
})
|
|
|
|
// --- Socket.IO Event Handlers ---
|
|
io.on('connection', (client) => {
|
|
log('info', `Socket connected: ${client.id}`)
|
|
|
|
client.on('message', async(message) => { // Made async if db ops become async
|
|
try {
|
|
// log('info', `Socket message from ${client.id}: Action - ${message.action}`);
|
|
if (!message || typeof message.action !== 'string') {
|
|
log('warn', `Invalid message format from ${client.id}:`, message)
|
|
return
|
|
}
|
|
|
|
// It's better to break this switch into separate functions for clarity
|
|
// For now, just ensuring basic safety and async potential
|
|
// All db operations should ideally be promisified and awaited
|
|
|
|
let responseAction; let responseData; let
|
|
broadcastData
|
|
const currentRoom = roomsManager.get_room(client) // Assuming this is synchronous
|
|
|
|
switch (message.action) {
|
|
case 'initializeMe':
|
|
await initClient(client, currentRoom) // Make initClient async
|
|
break
|
|
|
|
case 'joinRoom':
|
|
// roomName should be in message.data
|
|
const roomToJoin = scrub(message.data) // Assuming message.data is the room name
|
|
if (roomToJoin) {
|
|
await joinRoom(client, roomToJoin) // Make joinRoom async
|
|
client.emit('message', { action: 'roomAccept', data: { room: roomToJoin } })
|
|
} else {
|
|
log('warn', 'JoinRoom: Room name not provided or invalid.')
|
|
}
|
|
break
|
|
|
|
case 'editBoardMetas':
|
|
const { id: boardId, prop, value: boardValue } = message.data || {}
|
|
if (!boardId || !prop || typeof boardValue === 'undefined') break
|
|
const cleanBoardValue = scrub(boardValue)
|
|
dbInstance.editBoardMetas(currentRoom, boardId, prop, cleanBoardValue) // Assuming sync for now
|
|
broadcastData = { id: boardId, prop, value: cleanBoardValue }
|
|
broadcastToRoom(client, currentRoom, { action: 'editBoardMetas', data: broadcastData })
|
|
break
|
|
|
|
case 'moveCard':
|
|
const { id: cardIdMove, position } = message.data || {}
|
|
if (!cardIdMove || !position || typeof position.left === 'undefined' || typeof position.top === 'undefined') break
|
|
const cleanPos = { left: Number(scrub(position.left)), top: Number(scrub(position.top)) }
|
|
dbInstance.cardSetXY(currentRoom, cardIdMove, cleanPos.left, cleanPos.top) // Assuming sync
|
|
broadcastData = { id: scrub(cardIdMove), position: cleanPos }
|
|
broadcastToRoom(client, currentRoom, { action: 'moveCard', data: broadcastData })
|
|
break
|
|
|
|
case 'createCard':
|
|
const cardData = message.data || {}
|
|
// Add proper validation for all fields
|
|
const newCard = {
|
|
text: scrub(cardData.text),
|
|
id: scrub(cardData.id),
|
|
x: Number(scrub(cardData.x)),
|
|
y: Number(scrub(cardData.y)),
|
|
rot: Number(scrub(cardData.rot)),
|
|
colour: scrub(cardData.colour),
|
|
sticker: null // from original createCard function
|
|
}
|
|
if (!newCard.id) { log('warn', 'CreateCard: ID is missing.'); break }
|
|
dbInstance.createCard(currentRoom, newCard.id, newCard) // Assuming sync
|
|
broadcastToRoom(client, currentRoom, { action: 'createCard', data: newCard })
|
|
break
|
|
|
|
// ... (Continue refactoring other cases similarly)
|
|
// Ensure all data is scrubbed, validated, and db operations are clear.
|
|
// Use `await` if db operations become async.
|
|
|
|
case 'editCard':
|
|
const { id: cardIdEdit, value: cardValueEdit } = message.data || {}
|
|
if (!cardIdEdit || typeof cardValueEdit === 'undefined') break
|
|
const cleanCardValue = scrub(cardValueEdit)
|
|
dbInstance.cardEdit(currentRoom, cardIdEdit, cleanCardValue) // Assuming sync
|
|
broadcastData = { id: cardIdEdit, value: cleanCardValue }
|
|
broadcastToRoom(client, currentRoom, { action: 'editCard', data: broadcastData })
|
|
break
|
|
|
|
case 'deleteCard':
|
|
const { id: cardIdDelete } = message.data || {}
|
|
if (!cardIdDelete) break
|
|
const cleanCardIdDelete = scrub(cardIdDelete)
|
|
dbInstance.deleteCard(currentRoom, cleanCardIdDelete) // Assuming sync
|
|
broadcastToRoom(client, currentRoom, { action: 'deleteCard', data: { id: cleanCardIdDelete } })
|
|
break
|
|
|
|
// Add other cases here, applying similar logic:
|
|
// - Validate input from message.data
|
|
// - Scrub data
|
|
// - Call dbInstance methods (await if they become async)
|
|
// - broadcastToRoom with structured action and data
|
|
|
|
case 'setUserName':
|
|
const newUsername = scrub(message.data)
|
|
if (newUsername) {
|
|
setUserName(client, newUsername)
|
|
broadcastToRoom(client, currentRoom, {
|
|
action: 'nameChangeAnnounce',
|
|
data: { sid: client.id, user_name: newUsername }
|
|
})
|
|
}
|
|
break
|
|
|
|
// ... other cases like 'createColumn', 'addSticker', etc.
|
|
|
|
default:
|
|
log('info', `Unknown socket action from ${client.id}: ${message.action}`)
|
|
break
|
|
}
|
|
} catch (socketError) {
|
|
log('error', `Error in socket message handler (client: ${client.id}, action: ${message?.action}):`, socketError)
|
|
// Optionally emit an error back to the client
|
|
client.emit('message', { action: 'serverError', data: { message: 'An internal server error occurred.' } })
|
|
}
|
|
})
|
|
|
|
client.on('disconnect', (reason) => {
|
|
log('info', `Socket disconnected: ${client.id}, Reason: ${reason}`)
|
|
leaveRoom(client)
|
|
})
|
|
}) // end io.on('connection')
|
|
|
|
// --- Helper Functions (Socket & Business Logic) ---
|
|
// (Refactored with async/await where db calls are made, assuming db calls will be promisified)
|
|
|
|
async function initClient(client, room) {
|
|
log('info', `Initializing client ${client.id} for room ${room}`)
|
|
try {
|
|
// These db calls should ideally return promises
|
|
const cards = await new Promise((resolve, reject) => dbInstance.getAllCards(room, resolve))
|
|
client.emit('message', { action: 'initCards', data: cards || [] })
|
|
|
|
const columns = await new Promise((resolve, reject) => dbInstance.getAllColumns(room, resolve))
|
|
client.emit('message', { action: 'initColumns', data: columns || [] })
|
|
|
|
const revisionsData = await new Promise((resolve, reject) => dbInstance.getRevisions(room, resolve))
|
|
client.emit('message', { action: 'initRevisions', data: revisionsData ? Object.keys(revisionsData) : [] })
|
|
|
|
let theme = await new Promise((resolve, reject) => dbInstance.getTheme(room, resolve))
|
|
theme = theme === null ? 'bigcards' : theme
|
|
client.emit('message', { action: 'changeTheme', data: theme })
|
|
|
|
const boardSize = await new Promise((resolve, reject) => dbInstance.getBoardSize(room, resolve))
|
|
if (boardSize) {
|
|
client.emit('message', { action: 'setBoardSize', data: boardSize })
|
|
}
|
|
|
|
const roommateClients = roomsManager.room_clients(room) || []
|
|
const roommates = roommateClients
|
|
.filter((rc) => rc.id !== client.id)
|
|
.map((rc) => ({ sid: rc.id, user_name: sidsToUsernames.get(rc.id) || 'Anonymous' }))
|
|
client.emit('message', { action: 'initialUsers', data: roommates })
|
|
} catch (error) {
|
|
log('error', `Error initializing client ${client.id}:`, error)
|
|
client.emit('message', { action: 'initError', data: { message: 'Failed to initialize client data.' } })
|
|
}
|
|
}
|
|
|
|
async function joinRoom(client, roomName) { // roomName is the string name
|
|
log('info', `Client ${client.id} attempting to join room: ${roomName}`)
|
|
const announceMsg = { action: 'join-announce', data: { sid: client.id, user_name: sidsToUsernames.get(client.id) || 'Anonymous' } }
|
|
roomsManager.add_to_room_and_announce(client, roomName, announceMsg) // Assuming this updates client's room internally
|
|
// No explicit successFunction needed if client.emit is handled after this call
|
|
}
|
|
|
|
function leaveRoom(client) {
|
|
const currentRoom = roomsManager.get_room(client)
|
|
if (currentRoom) {
|
|
log('info', `Client ${client.id} leaving room: ${currentRoom}`)
|
|
const announceMsg = { action: 'leave-announce', data: { sid: client.id } }
|
|
roomsManager.remove_from_all_rooms_and_announce(client, announceMsg) // This should handle removal from its current room
|
|
}
|
|
sidsToUsernames.delete(client.id)
|
|
}
|
|
|
|
function broadcastToRoom(client, room, message) { // Pass explicit room
|
|
// Filter clients in the specific room, excluding the sender client
|
|
const targetClients = (roomsManager.room_clients(room) || [])
|
|
.filter((rc) => rc.id !== client.id)
|
|
targetClients.forEach((rc) => rc.emit('message', message))
|
|
}
|
|
|
|
function scrub(text) {
|
|
if (text === null || typeof text === 'undefined') return null
|
|
let strText = String(text)
|
|
if (strText.length > 65535) {
|
|
strText = strText.substring(0, 65535)
|
|
}
|
|
return sanitizer.sanitize(strText) // Ensure sanitizer is effective and secure
|
|
}
|
|
|
|
function setUserName(client, name) {
|
|
const cleanName = scrub(name) || 'Anonymous'
|
|
client.user_name = cleanName // Storing on client object if roomsManager uses it
|
|
sidsToUsernames.set(client.id, cleanName)
|
|
log('info', `Username for ${client.id} set to: ${cleanName}`)
|
|
}
|
|
|
|
function roundRand(max) {
|
|
return Math.floor(Math.random() * max)
|
|
}
|
|
|
|
function cleanAndInitializeDemoRoom() {
|
|
const demoRoom = '/demo'
|
|
log('info', `Initializing demo room: ${demoRoom}`)
|
|
dbInstance.clearRoom(demoRoom, () => { // Assuming clearRoom is async via callback
|
|
dbInstance.createColumn(demoRoom, 'Pas commencé')
|
|
dbInstance.createColumn(demoRoom, 'Commencé')
|
|
// ... (add other columns and cards for demo)
|
|
log('info', 'Demo room columns and cards created.')
|
|
|
|
// Example card creations:
|
|
const demoCards = [
|
|
{ id: 'card1', text: "Salut, c'est fun", colour: 'yellow' },
|
|
{ id: 'card2', text: "Salut, c'est une nouvelle histoire.", colour: 'white' }
|
|
]
|
|
demoCards.forEach((card) => {
|
|
dbInstance.createCard(demoRoom, card.id, {
|
|
id: card.id,
|
|
colour: card.colour,
|
|
rot: Math.random() * 10 - 5,
|
|
x: roundRand(600),
|
|
y: roundRand(300),
|
|
text: card.text,
|
|
sticker: null
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
// ... (Refactor exportBoard, exportJson, importJson, revision functions similarly)
|
|
// - Use async/await if db calls become promisified.
|
|
// - Ensure data is scrubbed.
|
|
// - Use client.emit for sending responses.
|
|
} catch (startupError) {
|
|
log('error', 'FATAL STARTUP ERROR in main:', startupError.stack || startupError)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
log('info', 'Calling main function...')
|
|
main().catch((mainError) => {
|
|
log('error', 'FATAL ERROR from main promise:', mainError.stack || mainError)
|
|
process.exit(1)
|
|
})
|
|
log('info', 'Main function called. Script will continue if server is listening.')
|