From 210631c9e5ce54dd48418e70dd257e92d09997a2 Mon Sep 17 00:00:00 2001 From: Ashelyn Rose Date: Sat, 10 May 2025 00:09:26 -0600 Subject: Working (mostly) Kind of broken on iOS --- index.js | 376 +++++++++++++++++++++++++++++++++++++++++++++++++++ static/index.html | 25 ++++ static/settings.html | 27 ++++ static/style.css | 195 ++++++++++++++++++++++++++ 4 files changed, 623 insertions(+) create mode 100644 index.js create mode 100644 static/index.html create mode 100644 static/settings.html create mode 100644 static/style.css 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 ` +
+ ${sanitizeText(args.nick)} + ${sanitizeText(args.content)} + ${(currentUserId === eventUserId) ? `
+ + +
+

Edit your message

+ + + +
+ + +
+
+
+
+ +
+
` : ''} +
` + + case 'delete': + return ` + ` + + case 'edit': + return ` + ` + + case 'nickChange': + return ` +
+ (${sanitizeText(args.oldNick)} changed name to ${sanitizeText(args.newNick)}) +
` + + case 'join': + return ` +
+ (${sanitizeText(args.nick)} joined the chat) +
` + + case 'part': + return ` +
+ (${sanitizeText(args.nick)} left the chat) +
` + } +} + +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, ''') +} 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 @@ + + + + + Slightly-less-minimally viable chat + + + + +
+
+ + +
+
+ +
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 @@ + + + + + Chat settings + + + + +
+

Chat Settings

+ + +
+ +
+
+ + 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; +} -- cgit 1.4.1