diff options
Diffstat (limited to 'index.js')
-rw-r--r-- | index.js | 376 |
1 files changed, 376 insertions, 0 deletions
diff --git a/index.js b/index.js new file mode 100644 index 0000000..d91d459 --- /dev/null +++ b/index.js @@ -0,0 +1,376 @@ +const path = require('node:path') +const {promises: {readFile}, createReadStream} = 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 + +const chatEvents = new EventEmitter() +let eventBuffer = [] +chatEvents.on('event', ev => { + eventBuffer = [...eventBuffer, ev].slice(0, MAX_EVENTS) + readline.clearLine() + console.log(JSON.stringify(ev)) + readline.prompt() +}) + +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 ` + <div class="message${(currentUserId === eventUserId) ? ' currentUser' : ''}" data-user-id="${sanitizeText(eventUserId)}" data-message-id="${sanitizeText(args.messageId)}"> + <span class="nick" title="${sanitizeText(eventUserId)}">${sanitizeText(args.nick)}</span> + <span class="content"><span class="orig">${sanitizeText(args.content)}</span></span> + ${(currentUserId === eventUserId) ? `<div class="actions"> + <button type="button" popovertarget="edit_${sanitizeText(args.messageId)}" popovertargetaction="toggle" aria-label="Edit" title="Edit"> + <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M200-200h57l391-391-57-57-391 391v57Zm-80 80v-170l528-527q12-11 26.5-17t30.5-6q16 0 31 6t26 18l55 56q12 11 17.5 26t5.5 30q0 16-5.5 30.5T817-647L290-120H120Zm640-584-56-56 56 56Zm-141 85-28-29 57 57-29-28Z"/></svg> + </button> + <dialog popover id="edit_${sanitizeText(args.messageId)}" class="edit"> + <form method="post" action="/edit"> + <h3>Edit your message</h3> + <input type="hidden" name="message_id" value="${sanitizeText(args.messageId)}"/> + <input type="text" autocomplete="off" name="new_content"/> + + <div class="buttonGroup"> + <button type="submit">Save edit</button> + <button type="button" popovertarget="edit_${sanitizeText(args.messageId)}" popovertargetaction="toggle">Cancel</button> + </div> + </form> + </dialog> + <form method="post" action="/delete"> + <button type="submit" name="message_id" value="${sanitizeText(args.messageId)}" aria-label="Delete" title="Delete"> + <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg> + </button> + </form> + </div>` : ''} + </div>` + + case 'delete': + return ` + <style> + .message[data-user-id="${sanitizeText(eventUserId)}"][data-message-id="${sanitizeText(args.messageId)}"] { + display: none; + } + </style>` + + case 'edit': + return ` + <style> + .message[data-user-id="${sanitizeText(eventUserId)}"][data-message-id="${sanitizeText(args.messageId)}"] { + .content .orig { + display: none; + } + + & .content::before { + content: '${args.content.replace(/' /g, "\\27\\ ").replace(/'/g, "\\27")}'; + } + + & .content::after { + content: ' (edited)'; + } + } + </style>` + + case 'nickChange': + return ` + <div class="nickChange"> + (<span class="nick" title="${sanitizeText(args.oldUserId)}">${sanitizeText(args.oldNick)}</span> changed name to <span class="new" title="${sanitizeText(args.newUserId)}">${sanitizeText(args.newNick)}</span>) + </div>` + + case 'join': + return ` + <div class="join"> + (<span class="nick" title="${sanitizeText(eventUserId)}">${sanitizeText(args.nick)}</span> joined the chat) + </div>` + + case 'part': + return ` + <div class="part"> + (<span class="nick" title="${sanitizeText(eventUserId)}">${sanitizeText(args.nick)}</span> left the chat) + </div>` + } +} + +function getUserId(pass) { + return createHash('md5').update(pass).digest('hex').slice(0,7) +} + +async function readBody(req) { + const bodyBuffer = Buffer.alloc(1000); + let offset = 0; + + return new Promise((resolve, reject) => { + req.on('data', chunk => { + const length = chunk.length + const written = bodyBuffer.write(chunk.toString('utf-8'), offset) + offset += chunk.length + if (written < length) { + reject({status: 413, message: 'Content too large'}) + } + }) + + req.on('end', () => { + try { + const body = bodyBuffer.slice(0, offset).toString('utf-8') + const params = new URLSearchParams(body) + resolve(params) + } catch { + reject({status: 400, message: 'Invalid body'}) + } + }) + }) +} + +function readCookie(req, res, name, defaultValue) { + const [_, value] = req.headers['cookie'] + ?.split('; ') + .map(c => c.split('=')) + .find(([n]) => n === name) || [] + + if (!value && defaultValue) { + writeCookie(res, name, defaultValue) + return defaultValue + } + + return value +} + +function writeCookie(res, name, value) { + res.appendHeader('set-cookie', `${name}=${value}; HttpOnly; SameSite=Strict`) +} + +function sanitizeText(str) { + return str + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} |