summary refs log tree commit diff
path: root/index.js
diff options
context:
space:
mode:
Diffstat (limited to 'index.js')
-rw-r--r--index.js376
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, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&apos;')
+}