const path = require('node:path') const {promises: {readFile}, readFileSync, createReadStream, createWriteStream} = require('node:fs') const {createServer} = require('node:http') const {createHash, randomBytes} = require('node:crypto') const Readline = require('node:readline'); const { stdin, stdout } = require('node:process'); const {EventEmitter} = require('node:events') const readline = Readline.createInterface({ input: stdin, output: stdout, prompt: '> ', }); const MAX_EVENTS = 100 // Event emitter and buffer for late joiners const chatEvents = new EventEmitter() let bufferDirty = true let eventBuffer = [] chatEvents.on('event', ev => { eventBuffer = [...eventBuffer, ev].slice(0, MAX_EVENTS) bufferDirty = true readline.clearLine() console.log(JSON.stringify(ev)) readline.prompt() }) // Populate buffer on startup const scrollbackFile = path.join(__dirname, '.scrollback') try { eventBuffer = readFileSync(scrollbackFile, {encoding: 'utf8'}).split('\n') .map(line => { try { return JSON.parse(line) } catch { return null } }).filter(event => !!event) bufferDirty = false } catch { } // Sync buffer to disk every second setInterval(async () => { if (!bufferDirty) return const fileStream = createWriteStream(scrollbackFile, {flags: 'w', encoding: 'utf8'}) for (const event of eventBuffer) { await new Promise(res => fileStream.write(JSON.stringify(event) + '\n', 'utf8', res)) } }, 1000) let currentUsers = {} readline.on('line', (line) => { readline.clearLine() Readline.moveCursor(stdout, 0, -2, () => { const [command, ...args] = line.split(' ') Readline.clearLine() switch (command) { case 'delete': const [messageId, ...rest] = args if (rest.length > 0) { console.log('Too many arguments. Usage:') console.log('> delete [messageID]') break } const before = eventBuffer.length eventBuffer = eventBuffer.filter(event => event?.args?.messageId !== messageId) const after = eventBuffer.length if (after < before) { console.log(`Removed ${before - after} events`) console.log('Resetting clients') chatEvents.emit('reset') } else { console.log(`ID ${messageId} did not match any events`) } break default: console.log('Unknown command: ' + command) } readline.prompt() }) }) createServer(async (req, res) => { const {method, url} = req const routes = { 'POST': { '/': handleMessagePost, '/delete': handleDeletePost, '/save': handleSavePost, '/edit': handleEditPost, }, 'GET': { '/': handleGetPage, '/settings': handleSettingsPage, '/style.css': handleGetStylesheet, }, } const handler = routes?.[method]?.[url] if (!handler) { res.writeHead(404).end('Not found') } else { try { await handler(req, res) } catch (err) { readline.clearLine() console.error(err) readline.prompt() res.writeHead(err.status || 500) .end(err.message || 'Unknown error') } } }).listen(process.env.port || 3000, () => { console.log('server started') readline.prompt() }) async function handleGetPage(req, res) { const nick = readCookie(req, res, 'semi-nick') const pass = readCookie(req, res, 'semi-pass') if (!nick || !pass) { return res.writeHead(303, {location: '/settings'}).end() } const userId = getUserId(pass) const initialResponse = createReadStream(path.join(__dirname, 'static/index.html')) res.writeHead(200, { 'content-type': 'text/html', 'refresh': '1', }) initialResponse.pipe(res, {end: false}) await new Promise(res => initialResponse.on('end', res)) for (const event of eventBuffer) { res.write(renderEvent(event, {userId, live: false})) } // Send enough bytes to ensure chunk is sent res.write(Array(1000).fill(' ').join(' ')) const onEvent = event => { const result = renderEvent(event, {userId, live: true}) if (result) res.write(result) } const onReset = () => { res.end() } chatEvents.on('event', onEvent) chatEvents.on('reset', onReset) const keepAlive = setInterval(() => { res.write(' ') currentUsers[userId] = true }, 100) const onDisconnect = () => { chatEvents.off('event', onEvent) chatEvents.off('reset', onReset) clearInterval(keepAlive) const nonce = randomBytes(20).toString('hex').slice(0, 7) currentUsers[userId] = nonce setTimeout(() => { if (currentUsers[userId] === nonce) { chatEvents.emit('event', {type: 'part', userId, args: {nick}}) delete currentUsers[userId] } }, 3000) } res.on('error', onDisconnect) res.on('close', onDisconnect) res.on('finish', onDisconnect) if (!(userId in currentUsers)) { chatEvents.emit('event', {type: 'join', userId, args: {nick}}) } currentUsers[userId] = true } async function handleSettingsPage(req, res) { const nick = readCookie(req, res, 'semi-nick') || '' const pass = readCookie(req, res, 'semi-pass') || '' const settingsTemplate = await readFile(path.join(__dirname, 'static/settings.html'), {encoding: 'utf8'}) res.writeHead(200, {'content-type': 'text/html'}) res.end(settingsTemplate .replace('[USERNICK]', nick) .replace('[USERPASS]', pass) ) } async function handleGetStylesheet(_req, res) { res.writeHead(200, {'content-type': 'text/css'}) const styleStream = createReadStream(path.join(__dirname, 'static/style.css')) styleStream.pipe(res) } async function handleMessagePost(req, res) { const body = await readBody(req) const content = body.get('message') const nick = readCookie(req, res, 'semi-nick', 'somebody') const pass = readCookie(req, res, 'semi-pass', randomBytes(20).toString('hex').slice(0, 7)) const userId = getUserId(pass) const messageId = randomBytes(20).toString('hex').slice(0, 7) if (content) chatEvents.emit('event', {type: 'message', userId, args: {messageId, content, nick}}) res.writeHead(303, {location: '/'}).end() } async function handleDeletePost(req, res) { const body = await readBody(req) const messageId = body.get('message_id') const pass = readCookie(req, res, 'semi-pass') const userId = getUserId(pass) if (messageId) chatEvents.emit('event', {type: 'delete', userId, args: {messageId}}) res.writeHead(303, {location: '/'}).end() } async function handleEditPost(req, res) { const body = await readBody(req) const messageId = body.get('message_id') const content = body.get('new_content') const pass = readCookie(req, res, 'semi-pass') const userId = getUserId(pass) if (messageId && content) chatEvents.emit('event', {type: 'edit', userId, args: {messageId, content}}) res.writeHead(303, {location: '/'}).end() } async function handleSavePost(req, res) { const body = await readBody(req) const oldNick = readCookie(req, res, 'semi-nick') const newNick = body.get('nick') || oldNick || 'somebody' const oldPass = readCookie(req, res, 'semi-pass') const oldUserId = oldPass ? getUserId(oldPass) : '[none]' const newPass = body.get('pass') || oldPass || randomBytes(20).toString('hex') const newUserId = getUserId(newPass) writeCookie(res, 'semi-nick', newNick) writeCookie(res, 'semi-pass', newPass) if ((oldNick && oldNick !== newNick)) chatEvents.emit('event', {type: 'nickChange', userId: oldUserId, args: {oldNick, newNick, oldUserId, newUserId}}) res.writeHead(303, {location: '/'}).end() } function renderEvent({type, userId: eventUserId, args}, {userId: currentUserId, live}) { switch (type) { case 'message': return `
` case 'delete': return ` ` case 'edit': return ` ` case 'nickChange': return `