From e71140e1faff0c2dd59614b34672e7af18881f31 Mon Sep 17 00:00:00 2001 From: oxalica Date: Tue, 27 Aug 2024 01:28:28 -0400 Subject: [PATCH] Impl example frontend --- pages/index.html | 73 +++++++++++++++ pages/main.js | 229 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 pages/index.html create mode 100644 pages/main.js diff --git a/pages/index.html b/pages/index.html new file mode 100644 index 0000000..b2ac964 --- /dev/null +++ b/pages/index.html @@ -0,0 +1,73 @@ + + + + + poc + + + + + +
+ please enter room url below and press ENTER +
+
+
+ + + +
+ +
+ + +
+
+ + +
+
+ + diff --git a/pages/main.js b/pages/main.js new file mode 100644 index 0000000..e89b241 --- /dev/null +++ b/pages/main.js @@ -0,0 +1,229 @@ +const msgFlow = document.querySelector('#msg-flow'); +const userPubkeyDisplay = document.querySelector('#user-pubkey'); +const roomUrlInput = document.querySelector('#room-url'); +const chatInput = document.querySelector('#chat'); +const regenKeyBtn = document.querySelector('#regen-key'); + +let roomUrl = ''; +let roomUuid = null; +let feed = null; +let keypair = null; + +function bufToHex(buf) { + return [...new Uint8Array(buf)] + .map(x => x.toString(16).padStart(2, '0')) + .join(''); +} + +function hexToBuf(hex) { + return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(m => parseInt(m, 16))) +} + +function appendMsg(el) { + msgFlow.append(el); + msgFlow.scrollTo({ + top: msgFlow.scrollTopMax, + behavior: 'instant', + }) +} + +function log(msg, isHtml) { + const el = document.createElement('span', {}); + el.classList.add('log'); + if (isHtml) { + el.innerHTML = msg; + } else { + el.innerText = msg; + } + appendMsg(el) +} + +async function loadKeypair() { + try { + const rawJson = localStorage.getItem('keypair'); + if (rawJson === null) return false; + const json = JSON.parse(rawJson) + keypair = { + publicKey: await crypto.subtle.importKey('jwk', json.publicKey, { name: 'Ed25519' }, true, ['verify']), + privateKey: await crypto.subtle.importKey('jwk', json.privateKey, { name: 'Ed25519' }, true, ['sign']), + }; + log('loaded keypair from localStorage'); + return true; + } catch (e) { + console.error(e); + log('failed to load keypair from localStorage'); + return false; + } +} + +async function generateKeypair() { + log('generating keypair'); + regenKeyBtn.disabled = true; + chatInput.disabled = true; + try { + keypair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']); + } catch (e) { + console.error('keygen', e); + chatInput.disabled = true; + log( + `failed to generate keypair, posting is disabled. maybe try Firefox or Safari? + + check caniuse.com + + `, + true + ); + } + + log('keypair generated'); + + regenKeyBtn.disabled = false; + chatInput.disabled = false; + + try { + const ser = (k) => crypto.subtle.exportKey('jwk', k); + localStorage.setItem('keypair', JSON.stringify({ + publicKey: await ser(keypair.publicKey), + privateKey: await ser(keypair.privateKey), + })); + } catch (e) { + console.error(e); + log('failed to store keypair into localStorage'); + } +} + +async function showChatMsg(chat) { + let verifyRet = null; + crypto.subtle.exportKey('raw', keypair.publicKey) + try { + const signeeBytes = (new TextEncoder()).encode(JSON.stringify(chat.signee)); + const rawkey = hexToBuf(chat.signee.user); + const senderKey = await crypto.subtle.importKey('raw', rawkey, { name: 'Ed25519' }, true, ['verify']); + const success = await crypto.subtle.verify('Ed25519', senderKey, hexToBuf(chat.sig), signeeBytes); + verifyRet = success ? '✔️' : '✖️'; + } catch (e) { + console.error(e); + verifyRet = `✖️ ${e}`; + } + + const shortUser = chat.signee.user.replace(/^(.{4}).*(.{4})$/, '$1…$2'); + const time = new Date(chat.signee.timestamp * 1000).toISOString(); + + const el = document.createElement('div', {}); + el.classList.add('msg'); + el.innerText = `${shortUser} [${time}] [${verifyRet}]: ${chat.signee.payload.text}`; + appendMsg(el) +} + +async function connectRoom(url) { + if (url === '' || url == roomUrl) return; + const match = url.match(/^https?:\/\/.*\/([a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})\/?/); + if (match === null) { + log('invalid room url'); + return; + } + + if (feed !== null) { + feed.close(); + } + roomUrl = url; + roomUuid = match[1]; + + log(`fetching room: ${url}`); + + fetch(`${url}/item`) + .then((resp) => resp.json()) + // TODO: This response format is to-be-decided. + .then(async (json) => { + const [{ title }, items] = json + document.title = `room: ${title}` + items.reverse(); + for (const [_cid, chat] of items) { + await showChatMsg(chat); + } + log('---history---') + }); + + // TODO: There is a time window where events would be lost. + + feed = new EventSource(`${url}/event`); + feed.onopen = (_) => { + log('listening on events'); + } + feed.onerror = (e) => { + console.error(e); + log('event listener error'); + }; + feed.onmessage = async (e) => { + console.log('feed event', e.data); + const chat = JSON.parse(e.data); + showChatMsg(chat); + }; +} + +async function postChat(text) { + text = text.trim(); + if (keypair === null || roomUuid === null || text === '') return; + + chatInput.disabled = true; + + const userKey = bufToHex(await crypto.subtle.exportKey('raw', keypair.publicKey)); + const nonceBuf = new Uint32Array(1); + crypto.getRandomValues(nonceBuf); + const timestamp = (Number(new Date()) / 1000) | 0; + const signee = { + nonce: nonceBuf[0], + payload: { + typ: 'chat', + room: roomUuid, + text, + }, + timestamp, + user: userKey, + }; + + const signeeBytes = (new TextEncoder()).encode(JSON.stringify(signee)); + const sig = await crypto.subtle.sign('Ed25519', keypair.privateKey, signeeBytes); + + const payload = JSON.stringify({ sig: bufToHex(sig), signee }); + try { + const resp = await fetch(`${roomUrl}/item`, { + method: 'POST', + cache: 'no-cache', + body: payload, + headers: { + 'Content-Type': 'application/json', + }, + }); + if (!resp.ok) throw new Error(`status ${resp.status} ${resp.statusText}`); + chatInput.value = ''; + } catch (e) { + console.error(e); + log(`failed to post chat: ${e}`); + } finally { + chatInput.disabled = false; + } +} + +window.onload = async (_) => { + if (!await loadKeypair()) { + await generateKeypair(); + } + if (keypair !== null) { + userPubkeyDisplay.value = bufToHex(await crypto.subtle.exportKey('raw', keypair.publicKey)); + } + connectRoom(roomUrlInput.value); +}; +roomUrlInput.onchange = (e) => { + connectRoom(e.target.value); +}; +chatInput.onkeypress = (e) => { + if (e.key === 'Enter') { + chatInput.disabled = true; + postChat(chatInput.value); + chatInput.disabled = false; + } +}; +regenKeyBtn.onclick = (_) => { + generateKeypair(); +};