mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-05-01 08:41:09 +00:00
Impl example frontend
This commit is contained in:
parent
0c905f61cf
commit
e71140e1fa
2 changed files with 302 additions and 0 deletions
73
pages/index.html
Normal file
73
pages/index.html
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>poc</title>
|
||||||
|
<script src="./main.js" defer></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
#msg-flow {
|
||||||
|
flex: 1;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
#msg-flow > * {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.log {
|
||||||
|
margin: auto;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.log::before {
|
||||||
|
content: "«";
|
||||||
|
}
|
||||||
|
.log::after {
|
||||||
|
content: "»";
|
||||||
|
}
|
||||||
|
|
||||||
|
#input-area > * {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
#input-area > * > label {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
#input-area > * > input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="msg-flow">
|
||||||
|
<span class="log">please enter room url below and press ENTER</span>
|
||||||
|
</div>
|
||||||
|
<div id="input-area">
|
||||||
|
<div>
|
||||||
|
<label for="user-pubkey">user pubkey:</label>
|
||||||
|
<input type="text" id="user-pubkey" placeholder="-" readonly />
|
||||||
|
<button id="regen-key">regenerate</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="room-url">room url:</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="room-url"
|
||||||
|
placeholder="https://example.com"
|
||||||
|
pattern="https://.*"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="chat">chat:</label>
|
||||||
|
<input type="text" id="chat" placeholder="message" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
229
pages/main.js
Normal file
229
pages/main.js
Normal file
|
@ -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?
|
||||||
|
<a target="_blank" href="https://caniuse.com/mdn-api_subtlecrypto_sign_ed25519">
|
||||||
|
check caniuse.com
|
||||||
|
</a>
|
||||||
|
`,
|
||||||
|
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();
|
||||||
|
};
|
Loading…
Add table
Reference in a new issue