summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--index.js376
-rw-r--r--static/index.html25
-rw-r--r--static/settings.html27
-rw-r--r--static/style.css195
4 files changed, 623 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;')
+}
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..efaf070
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,25 @@
+<!doctype html>
+<html>
+  <head>
+    <meta charset="UTF-8"/>
+    <title>Slightly-less-minimally viable chat</title>
+    <link rel="stylesheet" href="/style.css"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+  </head>
+  <body>
+    <div id="chat" method="post" action="/">
+      <form method="post" action="/">
+        <input autofocus autocomplete="off" type="text" name="message" value="" />
+        <button type="submit">
+          <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M120-160v-640l760 320-760 320Zm80-120 474-200-474-200v140l240 60-240 60v140Zm0 0v-400 400Z"/></svg>
+        </button>
+      </form>
+    </div>
+    <div id="footer">
+      <p>created by tempest-vi</p>
+      <button type="button" popovertarget="settings_modal" popovertargetaction="toggle">Settings</button>
+      <dialog popover id="settings_modal">
+        <iframe width="100%" height="100%" style="border: none;" src="/settings"></iframe>
+      </dialog>
+    </div>
+    <div id="messages">
diff --git a/static/settings.html b/static/settings.html
new file mode 100644
index 0000000..4ff6cbb
--- /dev/null
+++ b/static/settings.html
@@ -0,0 +1,27 @@
+<!doctype html>
+<html lang="en-US">
+  <head>
+    <meta charset="UTF-8"/>
+    <title>Chat settings</title>
+    <link rel="stylesheet" href="/style.css"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+  </head>
+  <body id="settings">
+    <form method="post" action="/save" target="_top">
+      <h3>Chat Settings</h3>
+      <label>
+        Nickname:
+        <input autocomplete="off" type="text" name="nick" value="[USERNICK]" placeholder="this one" required />
+        <span class="hint">What name should messages send with?</span>
+      </label>
+      <label>
+        Client ID:
+        <input autocomplete="off" type="password" name="pass" value="[USERPASS]" placeholder="(automatic)"/>
+        <span class="hint">Secret value used to verify identity between visits  (leave this blank to generate a random one)</span>
+      </label>
+      <div class="buttonGroup">
+        <button type="submit">Save</button>
+      </div>
+    </form>
+  </body>
+</html>
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..b50a57f
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,195 @@
+html, body {
+  margin: 0;
+  padding: 0;
+  width: 100vw;
+}
+
+html {
+  overflow: hidden;
+}
+
+body {
+  width: 100%;
+  max-height: 100vh;
+  display: flex;
+  flex-direction: column;
+  justify-content: end;
+  height: 100%;
+  position: fixed;
+  overflow-y: hidden;
+  -webkit-overflow-scrolling: touch;
+}
+
+body > div#messages {
+  order: -1;
+  display: grid;
+  grid-template-columns: fit-content(120px) 1fr fit-content(80px);
+}
+
+.message {
+  display: contents;
+}
+
+.message .nick {
+  padding: 4px 8px;
+  padding-left: 16px;
+  grid-column: 1 / 2;
+  overflow-x: hidden;
+  text-wrap: nowrap;
+  text-overflow: ellipsis;
+  box-sizing: border-box;
+  align-content: center;
+}
+
+.message .content {
+  grid-column: 2 / 4;
+  padding: 4px 8px;
+  align-content: center;
+}
+
+.message:has(.actions) .content {
+  grid-column: 2 / 3;
+}
+
+.message .actions {
+  padding: 0 8px;
+  display: flex;
+  flex-direction: row;
+  justify-content: end;
+  align-items: center;
+  grid-column: 3 / 4;
+  align-content: center;
+}
+
+.message .actions > button,
+.message .actions > form button {
+  background: none;
+  border: none;
+}
+
+@media (pointer:fine) {
+  .message .actions button {
+    opacity: 0;
+    pointer-events: none;
+  }
+}
+
+.message:hover,
+.message:has(.actions dialog[popover]:popover-open) {
+  & .actions button {
+    opacity: 1;
+    pointer-events: initial;
+    cursor: pointer;
+  }
+}
+
+.message.currentUser {
+  & > * {
+    /* background: #def6ff; */
+  }
+
+  & > .nick {
+    border-left: solid 6px #88dbfb;
+    padding-left: 10px;
+  }
+}
+
+.nickChange,
+.join,
+.part {
+  grid-column: 1 / 4;
+  opacity: .4;
+  padding: 4px 16px;
+  background: #d3d3d342;
+  font-size: .95em;
+}
+
+#chat {
+  order: 1;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 0 8px;
+  margin-top: 8px;
+}
+
+#chat > form {
+  display: contents;
+}
+
+#chat > form input {
+  flex: 1;
+  padding: 8px;
+}
+
+#chat button {
+  margin-left: 8px;
+  border: none;
+  background: none;
+}
+
+#footer {
+  order: 2;
+  padding: 4px 8px;
+  display: flex;
+  flex-direction: row;
+  justify-content: end;
+
+  & p {
+    margin: 0;
+    text-align: right;
+    font-style: italic;
+  }
+
+  & > button {
+    color: blue;
+    background: none;
+    border: none;
+    margin-left: 16px;
+    cursor: pointer;
+  }
+}
+
+#settings_modal {
+  height: 300px;
+  padding: none;
+  overflow: hidden;
+
+  & > iframe {
+    margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+  }
+}
+
+#settings {
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  justify-content: center;
+  width: calc(100% - 16px);
+  max-width: 600px;
+  margin: 0 auto;
+  position: initial;
+  min-height: 100vh;
+}
+
+#settings form {
+  display: contents;
+}
+
+#settings form h3 {
+  margin: 0;
+  margin-bottom: 8px;
+}
+
+#settings form label {
+  display: block;
+  margin-bottom: 8px;
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+}