memo/server.js
Florian Schmitt 28071e585f wip refacto
2025-06-08 18:27:17 +03:00

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.')