diff options
| author | 2026-02-27 23:37:44 +0330 | |
|---|---|---|
| committer | 2026-02-27 23:37:44 +0330 | |
| commit | 8ee3112ca09fd70a50c2159629b40700beb2d5c3 (patch) | |
| tree | 42a8d3e5d7898e9d084f77e1345be270be0f5969 /client | |
| parent | small check (diff) | |
first client ver
Diffstat (limited to 'client')
| -rw-r--r-- | client/.gitignore | 34 | ||||
| -rw-r--r-- | client/README.md | 15 | ||||
| -rw-r--r-- | client/bun.lock | 49 | ||||
| -rw-r--r-- | client/package.json | 19 | ||||
| -rw-r--r-- | client/public/blobs.ts | 154 | ||||
| -rw-r--r-- | client/public/chat.ts | 758 | ||||
| -rw-r--r-- | client/public/crypto.ts | 360 | ||||
| -rw-r--r-- | client/public/index.html | 77 | ||||
| -rw-r--r-- | client/public/stuff.ts | 58 | ||||
| -rw-r--r-- | client/public/style.css | 1164 | ||||
| -rw-r--r-- | client/server.ts | 8 | ||||
| -rw-r--r-- | client/tsconfig.json | 29 |
12 files changed, 2725 insertions, 0 deletions
diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..1e1303f --- /dev/null +++ b/client/README.md @@ -0,0 +1,15 @@ +# client + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run +``` + +This project was created using `bun init` in bun v1.3.2. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/client/bun.lock b/client/bun.lock new file mode 100644 index 0000000..dddd902 --- /dev/null +++ b/client/bun.lock @@ -0,0 +1,49 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "client", + "dependencies": { + "@noble/ciphers": "^2.1.1", + "@noble/curves": "^2.0.1", + "@noble/ed25519": "^3.0.0", + "@noble/hashes": "^2.0.1", + "@types/node-forge": "^1.3.14", + "aes-js": "^3.1.2", + "node-forge": "^1.3.3", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], + + "@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], + + "@noble/ed25519": ["@noble/ed25519@3.0.0", "", {}, "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg=="], + + "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="], + + "aes-js": ["aes-js@3.1.2", "", {}, "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ=="], + + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "node-forge": ["node-forge@1.3.3", "", {}, "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..c2b56b6 --- /dev/null +++ b/client/package.json @@ -0,0 +1,19 @@ +{ + "name": "client", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@noble/ciphers": "^2.1.1", + "@noble/curves": "^2.0.1", + "@noble/ed25519": "^3.0.0", + "@noble/hashes": "^2.0.1", + "@types/node-forge": "^1.3.14", + "aes-js": "^3.1.2", + "node-forge": "^1.3.3" + } +} diff --git a/client/public/blobs.ts b/client/public/blobs.ts new file mode 100644 index 0000000..5ba99e9 --- /dev/null +++ b/client/public/blobs.ts @@ -0,0 +1,154 @@ +const TAU = Math.PI * 2 + +type orb = { + color: string + cx: number + cy: number + rx: number + ry: number + fx: number + fy: number + px: number + py: number + sw: number + sh: number +} +export type html = HTMLElement & { _orbs: orb[], _seed: number } + +function initBlob(el: html) { + const colors = Array.from({ length: 8 }, (_, i) => el.getAttribute(`data-c${i + 1}`) || '#ffffff') + const seed = Math.random() * 1000 + + const orbs = colors.map((color, i) => ({ + color, + cx: 15 + Math.random() * 70, + cy: 15 + Math.random() * 70, + rx: 8 + Math.random() * 14, + ry: 8 + Math.random() * 14, + fx: 0.018 + Math.random() * 0.022, + fy: 0.015 + Math.random() * 0.020, + px: Math.random() * TAU, + py: Math.random() * TAU, + sw: 70 + Math.random() * 30, + sh: 60 + Math.random() * 35, + })) + + el._orbs = orbs + el._seed = seed +} + +function renderBlob(el: html, t: number) { + const layers = el._orbs.map(o => { + const x = o.cx + Math.sin(TAU * o.fx * t + o.px) * o.rx + const y = o.cy + Math.cos(TAU * o.fy * t + o.py) * o.ry + return `radial-gradient(ellipse ${o.sw}% ${o.sh}% at ${x.toFixed(1)}% ${y.toFixed(1)}%, ${o.color} 0%, transparent 85%)` + }) + const c0 = el._orbs[0]!.color, c1 = el._orbs[4]!.color + layers.push(`linear-gradient(135deg, ${c0} 0%, ${c1} 100%)`) + el.style.background = layers.join(',') +} + +const blobs = Array.from(document.querySelectorAll('blob')) as html[] +blobs.forEach(initBlob) + +let last = 0 +function tick(ts: number) { + const t = ts / 250 + if (ts - last >= 40) { + blobs.forEach(b => renderBlob(b, t)) + last = ts + } + requestAnimationFrame(tick) +} +requestAnimationFrame(tick) + +export function createBlob(seeds: [number, number, number, number, number, number, number, number]) { + function seedToColor(n: number) { + n = Math.abs(n) | 0 + const r = (n * 1664525 + 1013904223) & 0xff + const g = (n * 22695477 + 1) & 0xff + const b = (n * 214013 + 2531011) & 0xff + return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}` + } + + const colors = seeds.map(seedToColor) + + const blob = document.createElement('blob') as html + colors.forEach((c, i) => blob.setAttribute(`data-c${i + 1}`, c)) + initBlob(blob) + blobs.push(blob) + + return blob +} + +export let id_list: Record<string, HTMLElement[]> = {} + +export function pubkeyToBlobs(pubkey: string): [number, number, number, number, number, number, number, number] { + let bytes: Uint8Array + + const b64 = pubkey.replace(/-/g, '+').replace(/_/g, '/') + const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=') + bytes = Uint8Array.from(atob(padded), c => c.charCodeAt(0)) + + const step = Math.floor(bytes.length / 8) + return Array.from({ length: 8 }, (_, i) => bytes[i * step]!) as [number, number, number, number, number, number, number, number] +} + +export const decoder = new TextDecoder() +export const encoder = new TextEncoder() + +export function addStrings(left: string, right: string): string { + const l = encoder.encode(left) + const r = encoder.encode(right) + const result = new Uint8Array(Math.max(l.length, r.length)) + for (let i = 0; i < result.length; i++) { + result[i] = (l[i] ?? 0) ^ (r[i] ?? 0) + } + return Array.from(result, b => b.toString(16).padStart(2, '0')).join('') +} + +export function subtractStrings(value: string, key: string): string { + const v = new Uint8Array(value.match(/.{2}/g)!.map(b => parseInt(b, 16))) + const k = encoder.encode(key) + const result = new Uint8Array(Math.max(v.length, k.length)) + for (let i = 0; i < result.length; i++) { + result[i] = (v[i] ?? 0) ^ (k[i] ?? 0) + } + return decoder.decode(result) +} + +export let info: { keypair: { identity: string, cryptPrivate: string, signPrivate: string } } = {} as any + +export let followed_chats: string[] = JSON.parse(localStorage.getItem('followed_chats') ?? '["main"]') +followed_chats = [...new Set(followed_chats)] + +// , seeds: [number, number, number, number, number, number, number, number] +export function makeMessage(type: 'in' | 'out' | 'broadcast' | 'system', id: number, prefix: string, text: string, pubkey: string): HTMLElement { + const row = document.createElement('div') + row.className = 'msg-row' + const seeds: [number, number, number, number, number, number, number, number] = pubkey === '0' ? [27, 28, 32, 45, 49, 58, 91, 28] : pubkeyToBlobs(pubkey) + + const blob = createBlob(seeds) + blob.addEventListener('click', () => { + if (confirm('do you want to open a direct message with this user?')) { + const chat = `@${pubkey}` + if (!followed_chats.includes(chat)) { + followed_chats.push(chat) + //@ts-expect-error + globalThis.refreshChatList() + } + //@ts-expect-error + globalThis.switchChat(chat) + } + }) + + const msg = document.createElement(`m-${type}`) + msg.innerHTML = `<span class="msg-prefix">${prefix}</span> ${text}` + + row.appendChild(blob) + row.appendChild(msg) + + if (!id_list[pubkey]) id_list[pubkey] = [row] + else id_list[pubkey].push(row) + return row +}
\ No newline at end of file diff --git a/client/public/chat.ts b/client/public/chat.ts new file mode 100644 index 0000000..4671a0c --- /dev/null +++ b/client/public/chat.ts @@ -0,0 +1,758 @@ +console.clear() + +import * as crypt from './crypto.ts' +import { addStrings, createBlob, decoder, encoder, followed_chats, id_list, info, makeMessage, pubkeyToBlobs, subtractStrings } from './blobs.ts' +import { hideModal, setModalHTML, showModal } from './stuff.ts' + +function byteSlice(str: string, size: number) { + return decoder.decode(encoder.encode(str).slice(0, size)) +} + +function byteSize(str: string) { + return encoder.encode(str).length +} + +const input = document.querySelector('input[type="text"]')! as HTMLInputElement +const btn = document.querySelector('.send-btn')! +const cont = document.querySelector('container')! + +const edot = document.querySelector('.dot')! +const estatus = document.querySelector('.status')! + +function status(dot: 'live' | 'connecting' | 'initializing' | 'disconnected', text: string) { + edot.className = `dot dot-${dot}` + estatus.innerHTML = text +} + +const socket = new WebSocket(`ws://${location.hostname}:10000/tcp/ws/localhost/5001`) +socket.binaryType = 'arraybuffer' + +socket.addEventListener('error', e => { + console.error(e) + status('disconnected', 'error') +}) + +socket.addEventListener('close', e => { + console.error(e) + status('disconnected', 'disconnected') +}) + +let inbox: any[] = [] + +socket.addEventListener('open', e => { + status('initializing', 'phase0:open') +}) + +socket.addEventListener('message', e => { + const data = e.data + inbox.push([Date.now(), data]) +}) + +let ws_uuid: string +let my_id: number +let my_mode: 'tcp' | 'udp' + +let my_name: string | undefined = localStorage.getItem('name') ?? undefined + +async function spinSocket() { + while (socket.readyState !== socket.OPEN) { + await new Promise(r => setTimeout(r, 10)) + } +} + +async function setPassword(_password: string) { + if (byteSize(_password) > 8) console.warn('warning: password truncated to eight bytes') + const part1 = byteSlice(_password, 8) + const part2 = new Array(Math.abs(part1.length)).fill(String.fromCharCode(1)).join('') + const password = part2 + part1 + + await spinSocket() + socket.send('P' + password) +} + +async function sendMessage(_password: string, message: string) { + if (byteSize(_password) > 8) console.warn('warning: password truncated to eight bytes') + const part1 = byteSlice(_password, 8) + const part2 = new Array(Math.abs(part1.length)).fill(String.fromCharCode(1)).join('') + const password = part2 + part1 + + await spinSocket() + socket.send( + 'M' + + password + + message + + '\n' + ) +} + +const msg_pass = '10000000' +let msgHandler: ((time: number, packet: string) => Promise<boolean>) | undefined + +let nicknames: Record<string, string> = JSON.parse(localStorage.getItem('nicknames') ?? '{}') +let pubkey_id_pairs: Record<number, string> = {} + +function shortenPubkey(key: string) { + return key.substring(0, 6) + key.substring(key.length - 4) +} + +function systemMessage(text: string) { + cont.appendChild(makeMessage('system', 0, '', text, '0')) +} + +async function parseTrio(trio: string) { + const first = trio.indexOf(':') + const second = trio.indexOf(':', first + 1) + if (first === -1 || second === -1) return undefined + + const pubkey = trio.substring(0, first) + const signature = trio.substring(first + 1, second) + const data = trio.substring(second + 1).trim() + + if (await crypt.verify(pubkey.substring(pubkey.length / 2), encoder.encode(data), signature)) return { pubkey, signature, data } + console.log('not legit :(', pubkey, signature, data) + return undefined +} + +let chats: Record<string, HTMLElement[]> = {} +let current_chat: string = localStorage.getItem('active_chat') ?? 'main' + +type StoredMessage = { + obj: Record<string, any> + meta: { id: number, pubkey: string, nickname: string } + direction: 'in' | 'out' | 'system' +} +let chatData: Record<string, StoredMessage[]> = JSON.parse(localStorage.getItem('chat_data') ?? '{}') + +function persistChats() { + localStorage.setItem('chat_data', JSON.stringify(chatData)) +} + +function addMessage(chat: string, stored: StoredMessage) { + if (!chats[chat]) chats[chat] = [] + if (!chatData[chat]) chatData[chat] = [] + + chatData[chat]!.push(stored) + chatData[chat] = chatData[chat]! + persistChats() + + const elm = renderMessage(stored) + chats[chat]!.push(elm) + return elm +} + +function renderMessage(stored: StoredMessage) { + const { obj, meta, direction } = stored + const nick = nicknames[meta.pubkey] ?? meta.id + const prefix = direction === 'system' ? '' : `[<name>${nick}</name> ${shortenPubkey(meta.pubkey)}]` + return makeMessage(direction, meta.id, prefix, obj.text, meta.pubkey) +} + +function switchChat(next_chat: string) { + current_chat = next_chat + cont.innerHTML = '' + if (chats[current_chat]) { + for (const elm of chats[current_chat]!) { + cont.appendChild(elm) + } + } + + localStorage.setItem('active_chat', current_chat) + + refreshChatList() +} + +//@ts-expect-error +globalThis.switchChat = switchChat + +const pendingPings = new Map<string, (pubkey: string) => void>() + +function ping(targetPubkey: string, timeoutMs = 5000): Promise<boolean> { + const nonce = crypt.toB64(crypto.getRandomValues(new Uint8Array(12))) + return new Promise(resolve => { + const timer = setTimeout(() => { + pendingPings.delete(nonce) + resolve(false) + }, timeoutMs) + pendingPings.set(nonce, () => { + clearTimeout(timer) + resolve(true) + }) + sendMessage(msg_pass, `ping:${targetPubkey}:${nonce}`) + }) +} + +let CHUNK_SIZE = 1024 +const pendingReceives = new Map<string, { name: string, mime: string, total: number, chunks: Map<number, Uint8Array> }>() +const pendingSends = new Map<string, { targetPubkey: string, chunks: Uint8Array[], onProgress?: (n: number, total: number) => void }>() + +async function sendFile(targetPubkey: string, file: File, onProgress?: (n: number, total: number) => void) { + const buf = await file.arrayBuffer() + const bytes = new Uint8Array(buf) + const chunks: Uint8Array[] = [] + for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { + chunks.push(bytes.slice(i, i + CHUNK_SIZE)) + } + + const id = crypt.toB64(crypto.getRandomValues(new Uint8Array(12))) + pendingSends.set(id, { targetPubkey, chunks, onProgress }) + + await sendMessage(msg_pass, `file:offer:${targetPubkey}:${await createTrio(JSON.stringify({ + id, name: file.name, size: file.size, totalChunks: chunks.length, mime: file.type + }))}`) +} + +async function flushChunks(id: string) { + const send = pendingSends.get(id) + if (!send) return + const { targetPubkey, chunks, onProgress } = send + + for (let i = 0; i < chunks.length; i++) { + const encrypted = await crypt.encryptAsymmetric(targetPubkey.substring(0, targetPubkey.length / 2), chunks[i]!) + await sendMessage(msg_pass, `file:chunk:${targetPubkey}:${i}:${await createTrio(id + ':' + encrypted)}`) + onProgress?.(i + 1, chunks.length) + } + + await sendMessage(msg_pass, `file:done:${await createTrio(id)}`) + pendingSends.delete(id) +} + +const pendingOffers = new Map<string, { name: string, mime: string, size: number, totalChunks: number, senderPubkey: string }>() +const TRANSFERS_CHAT = '__transfers__' + +document.querySelector('s-item[archived]')!.addEventListener('click', () => { + switchChat(TRANSFERS_CHAT) +}) + +async function chatHandler(time: number, packet: string) { + // console.log('catching all packets!', time, packet) + + if (packet.startsWith('M')) { + packet = packet.substring(1) + const idstr = packet.split(':')[0]! + let id = Number(idstr) + if (isNaN(id)) id = 1 // fallback to some weird amount + packet = packet.substring(idstr.length + 1 + 1 + 1 + 10 + 1 + 8) // id : {T <timestamp> } password + + if (packet.startsWith('pubkey:')) { // announcement + // no use for this currently + const pubkey = packet.substring(7).trim() + pubkey_id_pairs[id] = pubkey + // systemMessage(`${shortenPubkey(pubkey)} has joined the chat.`) + return true + } + + if (packet.startsWith('ping:')) { // check if user is online + // anatomy -- + // ping : target_pubkey : 16-char nonce + packet = packet.substring(5) + const target = packet.split(':')[0] + if (!target) return false + if (target !== info.keypair.identity) return true + packet = packet.substring(target.length + 1) + const nonce = packet.trim() + if (nonce.length !== 16) return false + + await sendMessage(msg_pass, `pong:${await createTrio(nonce)}`) + return true + } + + if (packet.startsWith('pong:')) { + // anatomy -- + // pong : target_pubkey : 16-char nonce : signature + packet = packet.substring(5) + const pong = await parseTrio(packet) + if (!pong) return true + + const nonce = pong.data.trim() + if (nonce.length !== 16) return true + + pendingPings.get(nonce)?.(pong.pubkey) + pendingPings.delete(nonce) + return true + } + + if (packet.startsWith('msg:')) { // actual message + // anatomy -- + // msg : pubkey : signature : message + packet = packet.substring(4) // msg: + const msg = await parseTrio(packet) + + if (msg) { + pubkey_id_pairs[id] = msg.pubkey + + try { + const obj = JSON.parse(msg.data) + if (obj.chat.startsWith('@')) { + const sidekey = subtractStrings(obj.chat.substring(1), info.keypair.identity) + obj.chat = '@' + sidekey + obj.text = decoder.decode(await crypt.decryptAsymmetric(info.keypair.cryptPrivate, obj.text)) + } + + if (obj.text && obj.chat && followed_chats.includes(obj.chat)) { + if (!chats[obj.chat]) chats[obj.chat] = [] + const msgobj = addMessage(obj.chat, { + obj, + meta: { id, pubkey: msg.pubkey, nickname: nicknames[msg.pubkey] ?? String(id) }, + direction: 'in' + }) + chats[obj.chat]!.push(msgobj) + + if (obj.chat === current_chat) { + cont.appendChild(msgobj) + } + } + } catch (e) { + console.warn('invalid json', e) + } + } + } + + if (packet.startsWith('nick:')) { // nickname announcement + // anatomy -- + // nick : pubkey : signature : message + packet = packet.substring(5) // nick: + const nick = await parseTrio(packet) + + if (nick) { + nick.data = nick.data.toLowerCase() + + // skip fraudulent nickname override + const ind = Object.values(nicknames).findIndex(e => e === nick.data) + const existingKey = ind === -1 ? '' : Object.keys(nicknames)[ind]! + if (ind !== -1 && shortenPubkey(existingKey) === nick.pubkey) return true + + nicknames[nick.pubkey] = nick.data + localStorage.setItem('nicknames', JSON.stringify(nicknames)) + pubkey_id_pairs[id] = nick.pubkey + + const nickname_elms = nick.pubkey ? id_list[nick.pubkey] : undefined + if (nickname_elms) { + for (const elm of nickname_elms) { + elm.querySelector('name')!.innerHTML = nick.data ?? id + } + } + } + } + + if (packet.startsWith('file:offer:')) { // ogt file offer + packet = packet.substring(11) + const colon = packet.indexOf(':') + const target = packet.substring(0, colon) + if (target !== info.keypair.identity) return true + packet = packet.substring(colon + 1) + + const msg = await parseTrio(packet) + if (!msg) return true + const { id, name, size, totalChunks, mime } = JSON.parse(msg.data) + + pendingOffers.set(id, { name, mime, size, totalChunks, senderPubkey: msg.pubkey }) + + if (!chats[TRANSFERS_CHAT]) chats[TRANSFERS_CHAT] = [] + + const sender = nicknames[msg.pubkey] ?? shortenPubkey(msg.pubkey) + const row = makeMessage('system', 0, '', `๐ <b>${sender}</b> wants to send <b>${name}</b> (${(size / 1024).toFixed(1)} KB) โ <u style="cursor:pointer" data-transfer-id="${id}">accept or reject</u>`, '0') + + row.querySelector('[data-transfer-id]')!.addEventListener('click', async () => { + const offer = pendingOffers.get(id) + if (!offer) { + // row.querySelector('[data-transfer-id]')!.textContent = '(already handled)' + return + } + + const accept = confirm(`Accept "${offer.name}" (${(offer.size / 1024).toFixed(1)} KB) from ${nicknames[offer.senderPubkey] ?? shortenPubkey(offer.senderPubkey)}?`) + pendingOffers.delete(id) + row.querySelector('[data-transfer-id]')!.textContent = accept ? '(accepted)' : '(rejected)' + row.setAttribute('handled', 'true') + const badgeCount = chats[TRANSFERS_CHAT]!.filter(e => !e.hasAttribute('handled')).length + if (badgeCount === 0) document.querySelector('s-item[archived')!.removeAttribute('badge') + else document.querySelector('s-item[archived]')!.setAttribute('badge', badgeCount.toString()) + + + if (!accept) { + await sendMessage(msg_pass, `file:abort:${await createTrio(id)}`) + return + } + + pendingReceives.set(id, { name: offer.name, mime: offer.mime, total: offer.totalChunks, chunks: new Map() }) + await sendMessage(msg_pass, `file:accept:${await createTrio(id)}`) + }) + + chats[TRANSFERS_CHAT]!.push(row) + document.querySelector('s-item[archived]')!.setAttribute('badge', chats[TRANSFERS_CHAT].filter(e => !e.hasAttribute('handled')).length.toString()) + if (current_chat === TRANSFERS_CHAT) cont.appendChild(row) + + return true + } + + if (packet.startsWith('file:accept:')) { // file accepted, send contents + packet = packet.substring(12) + const msg = await parseTrio(packet) + if (!msg) return true + const id = msg.data.trim() + await flushChunks(id) + return true + } + + if (packet.startsWith('file:chunk:')) { // got file chunk + packet = packet.substring(11) + const parts = packet.split(':') + const target = parts[0]! + if (target !== info.keypair.identity) return true + // skip target + index + const firstColon = packet.indexOf(':') + const secondColon = packet.indexOf(':', firstColon + 1) + const msg = await parseTrio(packet.substring(secondColon + 1)) + if (!msg) return true + const colonIdx = msg.data.indexOf(':') + const id = msg.data.trim().substring(0, colonIdx) + const data = msg.data.substring(colonIdx + 1) + + const receive = pendingReceives.get(id) + if (!receive) return true + + const decrypted = await crypt.decryptAsymmetric(info.keypair.cryptPrivate, data) + const index = parseInt(parts[1]!) + receive.chunks.set(index, decrypted) + status('live', `receiving ${receive.name}: ${receive.chunks.size}/${receive.total}`) + return true + } + + if (packet.startsWith('file:done:')) { // finished + packet = packet.substring(10) + const msg = await parseTrio(packet) + if (!msg) return true + const id = msg.data.trim() + + const receive = pendingReceives.get(id) + if (!receive) return true + + // assemble + const ordered = Array.from({ length: receive.total }, (_, i) => receive.chunks.get(i)!) + if (ordered.some(c => !c)) { + console.warn('file transfer incomplete') + return true + } + + const total = ordered.reduce((s, c) => s + c.length, 0) + const merged = new Uint8Array(total) + let off = 0 + for (const chunk of ordered) { + merged.set(chunk, off) + off += chunk.length + } + + const blob = new Blob([merged], { type: receive.mime }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = receive.name + a.click() + URL.revokeObjectURL(url) + + pendingReceives.delete(id) + status('live', 'live') + return true + } + + if (packet.startsWith('file:abort:')) { + packet = packet.substring(11) + const msg = await parseTrio(packet) + if (!msg) return true + const id = msg.data.trim() + pendingSends.delete(id) + pendingReceives.delete(id) + return true + } + } + + return true +} + +//@ts-expect-error yeah +globalThis.refreshChatList = refreshChatList + +function refreshChatList() { + localStorage.setItem('followed_chats', JSON.stringify(followed_chats)) + + const c_channels = document.querySelector('s-channels')! + const c_directs = document.querySelector('s-directs')! + c_channels.innerHTML = '' + c_directs.innerHTML = '' + for (const chat of followed_chats) { + const dm = chat.startsWith('@') + const target = dm ? c_directs : c_channels + const elm = document.createElement('s-item') + elm.setAttribute(dm ? 'dm' : 'channel', '') + elm.innerHTML = dm ? (nicknames[chat.substring(1)] ?? `${shortenPubkey(chat.substring(1))}?`) : chat + if (chat === current_chat) elm.setAttribute('active', '') + elm.addEventListener('click', () => { + switchChat(chat) + }) + + target.appendChild(elm) + } + + const transfersBtn = document.querySelector('s-item[archived]')! + transfersBtn.toggleAttribute('active', current_chat === TRANSFERS_CHAT) + transfersBtn.toggleAttribute('pending', pendingOffers.size > 0) +} + +async function runSetup() { + status('live', 'setup1:keypair') + const keypairText = localStorage.getItem('keypair') + if (keypairText) { + console.log('user aready has a keypair.') + info.keypair = JSON.parse(keypairText) + } else { + console.log('generating keypair...') + + let newpair: any + let confirmed = false + + showModal() + async function checkPair() { + const pair1 = await crypt.generateEncryptionKeyPair() + const pair2 = await crypt.generateSigningKeyPair() + + newpair = { + identity: pair1.publicKey + pair2.publicKey, + cryptPrivate: pair1.privateKey, + signPrivate: pair2.privateKey + } + + setModalHTML('') + + const blob = createBlob(pubkeyToBlobs(newpair.identity)) + blob.style.transform = 'scale(150%)' + blob.style.marginLeft = '10px' + blob.addEventListener('click', checkPair) + const skeleton = document.createElement('div') + + const header = document.createElement('div') + header.classList.add('modal-header') + header.innerHTML = '<span class="modal-title">generate a blob!</span>' + + const body = document.createElement('div') + body.classList.add('modal-body') + + const p3 = document.createElement('p') + p3.innerHTML = `this is a blob, the visual representation of your public key.<br> + +click on the blob until you're satisfied with the colors.<br><br>` + + body.appendChild(p3) + body.appendChild(blob) + + const btn = document.createElement('button') + btn.classList.add('btn') + btn.classList.add('btn-primary') + btn.innerHTML = 'done.' + btn.addEventListener('click', () => { + localStorage.setItem('keypair', JSON.stringify(newpair)) + info.keypair = newpair + confirmed = true + hideModal() + }) + body.appendChild(document.createElement('br')) + body.appendChild(document.createElement('br')) + body.appendChild(btn) + + skeleton.appendChild(header) + skeleton.appendChild(body) + + document.getElementById('modal-el')!.appendChild(skeleton) + } + + await checkPair() + while (!confirmed) await new Promise(r => setTimeout(r, 100)) + } + + document.querySelector('s-avatar')!.appendChild(createBlob(pubkeyToBlobs(info.keypair.identity))) + document.querySelector('s-name')!.innerHTML = my_name ?? `id${my_id}` + + msgHandler = chatHandler + + status('live', 'setup2:subscribe') + await setPassword(msg_pass) + status('live', 'setup3:announce') + await sendMessage(msg_pass, 'pubkey:' + info.keypair.identity) + if (my_name) { + status('live', 'setup4:nickname') + await sendMessage(msg_pass, `nick:${await createTrio(my_name)}`) + } + + status('live', 'setup5:chats') + refreshChatList() + + status('live', 'setup6:history') + for (const [chat, messages] of Object.entries(chatData)) { + chats[chat] = messages.map(renderMessage) + } + switchChat(current_chat) + + status('live', 'live') +} + +async function inboxProcessor() { + if (inbox.length === 0) return + const [time, packet] = inbox.shift() + let text + if (typeof packet === 'string') { + if (packet.startsWith('ok_')) { // broadlayer uuid + ws_uuid = packet.substring(3) + console.log('phase 0: got uuid') + status('initializing', 'shake0:uuid') + return + } + text = packet + } else { + text = decoder.decode(packet) + + if (text.startsWith('I')) { + const mode = text.startsWith('IT') ? 'tcp' : 'udp' + console.log('phase 1: connected on', mode, 'mode') + const idstr = text.substring(2).trim() + const id = parseInt(idstr) + if (isNaN(id)) { + console.warn('failed on nan id catch "' + text + '"') + return + } + + my_id = id + my_mode = mode + CHUNK_SIZE = my_mode === 'tcp' ? 8192 : 1024 + console.log('phase 1: got user id') + status('initializing', 'shake1:uid') + await runSetup() + return + } + + if (text.startsWith('T')) { + console.log('phase 2: udp server sent timeout, broadlayer handles it') + status('initializing', 'shake2:udptime') + return + } + + if (text.startsWith('m_ok') || text.startsWith('p_ok')) { + console.log('got an m_ok/p_ok, ignoring') + return + } + } + + if (msgHandler && await msgHandler(time, text)) return + + console.log('unknown/invalid packet', packet) +} +async function inboxProcessorLoop() { + const max = Math.max(inbox.length, 1) + for (let i = 0; i < max; i++) { + await inboxProcessor() + } + await new Promise(r => setTimeout(r, 100)) + requestAnimationFrame(inboxProcessorLoop) +} +requestAnimationFrame(inboxProcessorLoop) + +// setPassword('hello') + +async function createTrio(text: string) { + return `${info.keypair.identity}:${await crypt.sign(info.keypair.signPrivate, encoder.encode(text))}:${text}` +} + +async function _send() { + const val = input.value.trim() + if (!val) return + + if (val.startsWith('/')) { + const cmd = val.substring(1) + if (cmd.startsWith('nick ')) { + const nick = cmd.substring(5).trim() + + localStorage.setItem('name', nick) + my_name = nick + nicknames[info.keypair.identity] = nick + + const nickname_elms = id_list[info.keypair.identity] + if (nickname_elms) { + for (const elm of nickname_elms) { + elm.querySelector('name')!.innerHTML = nick ?? my_id + } + } + + document.querySelector('s-name')!.innerHTML = nick ?? `id${my_id}` + + // announce + await sendMessage(msg_pass, `nick:${await createTrio(nick)}`) + systemMessage('successfully set your nickname.') + return + } else if (cmd.startsWith('join ')) { + const chat = cmd.substring(5).trim() + if (!followed_chats.includes(chat)) { + followed_chats.push(chat) + refreshChatList() + } + + return + } + + systemMessage('unknown command.') + return + } + + let chat = current_chat + let text + if (chat.startsWith('@')) { + const sidekey = chat.substring(1) + chat = '@' + addStrings(sidekey, info.keypair.identity) + text = await crypt.encryptAsymmetric(sidekey.substring(0, sidekey.length / 2), encoder.encode(val)) + } else text = val + + await sendMessage(msg_pass, `msg:${await createTrio(JSON.stringify({ + text, + chat, + timestamp: Date.now() + }))}`) + + const msgobj = addMessage(current_chat, { + obj: { text: val, chat: current_chat }, + meta: { id: my_id, pubkey: info.keypair.identity, nickname: my_name ?? String(my_id) }, + direction: 'out' + }) + cont.appendChild(msgobj) + + if (!chats[current_chat]) chats[current_chat] = [] + chats[current_chat]!.push(msgobj) +} + +async function send() { + await _send() + cont.scrollTop = cont.scrollHeight + input.value = '' + input.focus() +} + +btn.addEventListener('click', send) +input.addEventListener('keydown', e => { if (e.key === 'Enter') send() }) + +const fileBtn = document.querySelector('.file-btn')! + +fileBtn.addEventListener('click', () => { + if (!current_chat.startsWith('@')) { + systemMessage('file transfer is only available in direct messages.') + return + } + + const input = document.createElement('input') + input.type = 'file' + input.addEventListener('change', async () => { + const file = input.files?.[0] + if (!file) return + + const targetPubkey = current_chat.substring(1) + await sendFile(targetPubkey, file, (n, total) => { + status('live', `sending ${file.name}: ${n}/${total}`) + }) + status('live', 'live') + }) + input.click() +})
\ No newline at end of file diff --git a/client/public/crypto.ts b/client/public/crypto.ts new file mode 100644 index 0000000..ab83517 --- /dev/null +++ b/client/public/crypto.ts @@ -0,0 +1,360 @@ +import { ed25519, x25519 } from '@noble/curves/ed25519.js' +import { hkdf } from '@noble/hashes/hkdf.js' +import { pbkdf2 } from '@noble/hashes/pbkdf2.js' +import { sha256 } from '@noble/hashes/sha2.js' +import { gcm } from '@noble/ciphers/aes.js' +import { randomBytes as nodeRandomBytes } from 'node:crypto' + +const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' + +export function toB64(bytes: Uint8Array): string { + let out = '' + let i = 0 + while (i < bytes.length) { + const a = bytes[i++]! + const b = bytes[i++] ?? 0 + const c = bytes[i++] ?? 0 + out += B64URL[a >> 2] + out += B64URL[((a & 3) << 4) | (b >> 4)] + out += B64URL[((b & 15) << 2) | (c >> 6)] + out += B64URL[c & 63] + } + const pad = bytes.length % 3 + return pad ? out.slice(0, pad - 3) : out +} + +export function fromB64(str: string): Uint8Array { + const lookup = new Uint8Array(128) + for (let i = 0; i < B64URL.length; i++) lookup[B64URL.charCodeAt(i)] = i + const pad = str.length % 4 + if (pad) str += '='.repeat(4 - pad) + const out = new Uint8Array((str.length * 3) >> 2) + let pos = 0 + for (let i = 0; i < str.length; i += 4) { + const a = lookup[str.charCodeAt(i)]! + const b = lookup[str.charCodeAt(i + 1)]! + const c = lookup[str.charCodeAt(i + 2)] ?? 0 + const d = lookup[str.charCodeAt(i + 3)] ?? 0 + out[pos++] = (a << 2) | (b >> 4) + out[pos++] = ((b & 15) << 4) | (c >> 2) + out[pos++] = ((c & 3) << 6) | d + } + return out.slice(0, pos - (str.endsWith('==') ? 2 : str.endsWith('=') ? 1 : 0)) +} + +function concat(...arrays: Uint8Array[]): Uint8Array { + const total = arrays.reduce((s, a) => s + a.length, 0) + const out = new Uint8Array(total) + let off = 0 + for (const a of arrays) { + out.set(a, off) + off += a.length + } + return out +} + +function toArrayBuffer(u8: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(u8.length) + copy.set(u8) + return copy.buffer as ArrayBuffer +} + +const encode = (s: string) => new TextEncoder().encode(s) +export const decodeText = (b: Uint8Array) => new TextDecoder().decode(b) + +const _g = globalThis as Record<string, unknown> +const hasWebCrypto: boolean = + typeof _g['crypto'] !== 'undefined' && + typeof (_g['crypto'] as Record<string, unknown>)['subtle'] !== 'undefined' + +const subtle: SubtleCrypto | null = hasWebCrypto + ? (_g['crypto'] as { subtle: SubtleCrypto }).subtle + : null + +function randomBytes(n: number): Uint8Array { + if (hasWebCrypto) { + const buf = new Uint8Array(n); + (_g['crypto'] as Crypto).getRandomValues(buf) + return buf + } + return new Uint8Array(nodeRandomBytes(n)) +} + +const _wc: Record<string, boolean | undefined> = {} + +async function wcSupports(alg: string): Promise<boolean> { + if (typeof _wc[alg] === 'boolean') return _wc[alg]! + if (!subtle) return (_wc[alg] = false) + try { + switch (alg) { + case 'Ed25519': + await subtle.generateKey({ name: 'Ed25519' } as EcKeyGenParams, false, ['sign', 'verify']) + break + case 'X25519': + await subtle.generateKey({ name: 'X25519' } as EcKeyGenParams, false, ['deriveBits']) + break + case 'AES-GCM': + await subtle.generateKey({ name: 'AES-GCM', length: 256 } as AesKeyGenParams, false, ['encrypt', 'decrypt']) + break + case 'HKDF': + await subtle.importKey('raw', new ArrayBuffer(32), { name: 'HKDF' }, false, ['deriveBits']) + break + case 'PBKDF2': + await subtle.importKey('raw', toArrayBuffer(encode('probe')), { name: 'PBKDF2' }, false, ['deriveBits']) + break + default: + return (_wc[alg] = false) + } + return (_wc[alg] = true) + } catch { + return (_wc[alg] = false) + } +} + +function toB64url(bytes: Uint8Array): string { + let s = '' + for (const b of bytes) s += String.fromCharCode(b) + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function fromB64url(b64u: string): Uint8Array { + const b64 = b64u.replace(/-/g, '+').replace(/_/g, '/') + const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=') + const bin = atob(padded) + return Uint8Array.from({ length: bin.length }, (_, i) => bin.charCodeAt(i)) +} + +export interface KeyPair { + privateKey: string + publicKey: string +} + +export async function generateSigningKeyPair(): Promise<KeyPair> { + if (await wcSupports('Ed25519')) { + const kp = await subtle!.generateKey({ name: 'Ed25519' } as EcKeyGenParams, true, ['sign', 'verify']) as CryptoKeyPair + const privJwk = await subtle!.exportKey('jwk', kp.privateKey) + const pubJwk = await subtle!.exportKey('jwk', kp.publicKey) + return { + privateKey: toB64(fromB64url(privJwk.d!)), + publicKey: toB64(fromB64url(pubJwk.x!)), + } + } + const priv = ed25519.utils.randomSecretKey() + return { privateKey: toB64(priv), publicKey: toB64(ed25519.getPublicKey(priv)) } +} + +export async function sign(privateKeyHex: string, message: Uint8Array): Promise<string> { + const priv = fromB64(privateKeyHex) + const pub = ed25519.getPublicKey(priv) + + if (await wcSupports('Ed25519')) { + const key = await subtle!.importKey( + 'jwk', + { kty: 'OKP', crv: 'Ed25519', d: toB64url(priv), x: toB64url(pub), key_ops: ['sign'] } as JsonWebKey, + { name: 'Ed25519' } as EcKeyImportParams, + false, + ['sign'] + ) + const sig = await subtle!.sign({ name: 'Ed25519' } as Algorithm, key, toArrayBuffer(message)) + return toB64(new Uint8Array(sig)) + } + + return toB64(ed25519.sign(message, priv)) +} + +export async function verify( + publicKeyHex: string, + message: Uint8Array, + signatureHex: string +): Promise<boolean> { + const pub = fromB64(publicKeyHex) + const sig = fromB64(signatureHex) + + if (await wcSupports('Ed25519')) { + const key = await subtle!.importKey( + 'jwk', + { kty: 'OKP', crv: 'Ed25519', x: toB64url(pub), key_ops: ['verify'] } as JsonWebKey, + { name: 'Ed25519' } as EcKeyImportParams, + false, + ['verify'] + ) + return subtle!.verify({ name: 'Ed25519' } as Algorithm, key, toArrayBuffer(sig), toArrayBuffer(message)) + } + + try { return ed25519.verify(sig, message, pub) } catch { return false } +} + +const ASYM_INFO = encode('x25519-aes256gcm-v1') +const IV_LEN = 12 +const TAG_LEN = 16 + +export async function generateEncryptionKeyPair(): Promise<KeyPair> { + if (await wcSupports('X25519')) { + const kp = await subtle!.generateKey({ name: 'X25519' } as EcKeyGenParams, true, ['deriveBits']) as CryptoKeyPair + const privJwk = await subtle!.exportKey('jwk', kp.privateKey) + const pubJwk = await subtle!.exportKey('jwk', kp.publicKey) + return { + privateKey: toB64(fromB64url(privJwk.d!)), + publicKey: toB64(fromB64url(pubJwk.x!)), + } + } + const priv = x25519.utils.randomSecretKey() + return { privateKey: toB64(priv), publicKey: toB64(x25519.getPublicKey(priv)) } +} + +async function dhSharedSecret(priv: Uint8Array, peerPub: Uint8Array): Promise<Uint8Array> { + if (await wcSupports('X25519')) { + const myPub = x25519.getPublicKey(priv) + const privKey = await subtle!.importKey( + 'jwk', + { kty: 'OKP', crv: 'X25519', d: toB64url(priv), x: toB64url(myPub) } as JsonWebKey, + { name: 'X25519' } as EcKeyImportParams, + false, + ['deriveBits'] + ) + const pubKey = await subtle!.importKey( + 'jwk', + { kty: 'OKP', crv: 'X25519', x: toB64url(peerPub) } as JsonWebKey, + { name: 'X25519' } as EcKeyImportParams, + false, + [] + ) + return new Uint8Array( + await subtle!.deriveBits({ name: 'X25519', public: pubKey } as EcdhKeyDeriveParams, privKey, 256) + ) + } + return x25519.getSharedSecret(priv, peerPub) +} + +async function hkdfDerive(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, outLen = 32): Promise<Uint8Array> { + if (await wcSupports('HKDF')) { + const ikmKey = await subtle!.importKey('raw', toArrayBuffer(ikm), { name: 'HKDF' }, false, ['deriveBits']) + return new Uint8Array( + await subtle!.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt: toArrayBuffer(salt), info: toArrayBuffer(info) } as HkdfParams, + ikmKey, + outLen * 8 + ) + ) + } + return hkdf(sha256, ikm, salt, info, outLen) +} + +export async function encryptAsymmetric(recipientPublicKeyHex: string, plaintext: Uint8Array): Promise<string> { + const recipientPub = fromB64(recipientPublicKeyHex) + + let ephPriv: Uint8Array, ephPub: Uint8Array + if (await wcSupports('X25519')) { + const kp = await subtle!.generateKey({ name: 'X25519' } as EcKeyGenParams, true, ['deriveBits']) as CryptoKeyPair + const privJwk = await subtle!.exportKey('jwk', kp.privateKey) + const pubJwk = await subtle!.exportKey('jwk', kp.publicKey) + ephPriv = fromB64url(privJwk.d!) + ephPub = fromB64url(pubJwk.x!) + } else { + ephPriv = x25519.utils.randomSecretKey() + ephPub = x25519.getPublicKey(ephPriv) + } + + const shared = await dhSharedSecret(ephPriv, recipientPub) + const aesKey = await hkdfDerive(shared, ephPub, ASYM_INFO, 32) + const iv = randomBytes(IV_LEN) + const enc = await aesEncrypt(aesKey, iv, plaintext) + + return toB64(concat(ephPub, iv, enc)) +} + +export async function decryptAsymmetric(privateKeyHex: string, ciphertextHex: string): Promise<Uint8Array> { + const priv = fromB64(privateKeyHex) + const packed = fromB64(ciphertextHex) + const minLen = 32 + IV_LEN + TAG_LEN + if (packed.length < minLen) + throw new Error(`Ciphertext too short: ${packed.length} < ${minLen} bytes`) + + let off = 0 + const ephPub = packed.slice(off, (off += 32)) + const iv = packed.slice(off, (off += IV_LEN)) + const enc = packed.slice(off) + + const shared = await dhSharedSecret(priv, ephPub) + const aesKey = await hkdfDerive(shared, ephPub, ASYM_INFO, 32) + return aesDecrypt(aesKey, iv, enc) +} + +export async function generateSymmetricKey(): Promise<string> { + if (await wcSupports('AES-GCM')) { + const key = await subtle!.generateKey({ name: 'AES-GCM', length: 256 } as AesKeyGenParams, true, ['encrypt', 'decrypt']) as CryptoKey + return toB64(new Uint8Array(await subtle!.exportKey('raw', key))) + } + return toB64(randomBytes(32)) +} + +export async function encryptSymmetric(keyHex: string, plaintext: Uint8Array): Promise<string> { + const key = fromB64(keyHex) + if (key.length !== 32) throw new Error('AES key must be 32 bytes (64 hex chars)') + const iv = randomBytes(IV_LEN) + return toB64(concat(iv, await aesEncrypt(key, iv, plaintext))) +} + +export async function decryptSymmetric(keyHex: string, ciphertextHex: string): Promise<Uint8Array> { + const key = fromB64(keyHex) + if (key.length !== 32) throw new Error('AES key must be 32 bytes (64 hex chars)') + const packed = fromB64(ciphertextHex) + if (packed.length < IV_LEN + TAG_LEN) + throw new Error(`Ciphertext too short: ${packed.length} < ${IV_LEN + TAG_LEN} bytes`) + return aesDecrypt(key, packed.slice(0, IV_LEN), packed.slice(IV_LEN)) +} + +export interface DerivedKey { + key: string + salt: string +} + +export async function deriveKeyFromPassword( + password: string, + saltHex?: string, + iterations = 310_000 +): Promise<DerivedKey> { + const salt = saltHex ? fromB64(saltHex) : randomBytes(16) + const pass = encode(password) + let keyBytes: Uint8Array + + if (await wcSupports('PBKDF2')) { + const baseKey = await subtle!.importKey('raw', toArrayBuffer(pass), { name: 'PBKDF2' }, false, ['deriveBits']) + keyBytes = new Uint8Array( + await subtle!.deriveBits( + { name: 'PBKDF2', hash: 'SHA-256', salt: toArrayBuffer(salt), iterations } as Pbkdf2Params, + baseKey, + 256 + ) + ) + } else { + keyBytes = pbkdf2(sha256, pass, salt, { c: iterations, dkLen: 32 }) + } + return { key: toB64(keyBytes), salt: toB64(salt) } +} + +export async function deriveKeyHKDF(ikmHex: string, info: string, saltHex?: string): Promise<string> { + const ikm = fromB64(ikmHex) + const salt = saltHex ? fromB64(saltHex) : new Uint8Array(32) + return toB64(await hkdfDerive(ikm, salt, encode(info), 32)) +} + +async function aesEncrypt(key: Uint8Array, iv: Uint8Array, plaintext: Uint8Array): Promise<Uint8Array> { + if (await wcSupports('AES-GCM')) { + const ck = await subtle!.importKey('raw', toArrayBuffer(key), 'AES-GCM', false, ['encrypt']) + return new Uint8Array( + await subtle!.encrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) } as AesGcmParams, ck, toArrayBuffer(plaintext)) + ) + } + return gcm(key, iv).encrypt(plaintext) +} + +async function aesDecrypt(key: Uint8Array, iv: Uint8Array, ct: Uint8Array): Promise<Uint8Array> { + if (await wcSupports('AES-GCM')) { + const ck = await subtle!.importKey('raw', toArrayBuffer(key), 'AES-GCM', false, ['decrypt']) + return new Uint8Array( + await subtle!.decrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) } as AesGcmParams, ck, toArrayBuffer(ct)) + ) + } + return gcm(key, iv).decrypt(ct) +}
\ No newline at end of file diff --git a/client/public/index.html b/client/public/index.html new file mode 100644 index 0000000..5ad6a1a --- /dev/null +++ b/client/public/index.html @@ -0,0 +1,77 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>chatcast</title> + <link href="https://fonts.googleapis.com/css2?family=Berkeley+Mono:ital@0;1&family=Syne:wght@400;700&display=swap" + rel="stylesheet"> + <link href="style.css" rel="stylesheet"> +</head> + +<body> + + <div class="header"> + <button id="sidebar-toggle" aria-label="Toggle sidebar" aria-expanded="false"> + <svg viewBox="0 0 13 11" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> + <rect y="0" width="13" height="1.3" rx="0.65" fill="currentColor" /> + <rect y="4.85" width="13" height="1.3" rx="0.65" fill="currentColor" /> + <rect y="9.7" width="13" height="1.3" rx="0.65" fill="currentColor" /> + </svg> + </button> + <span class="header-text">chatcast</span> + <span class="header-badge"><span class="dot dot-live"></span><text class="status">idle</text></span> + </div> + + <div id="sidebar-backdrop"></div> + + <div class="layout"> + + <sidebar id="sidebar"> + <s-head> + channels + <button id="sidebar-close" aria-label="close sidebar">โ</button> + </s-head> + + <s-body> + <s-item archived>file transfers</s-item> + + <s-cat>channels</s-cat> + <s-channels> + </s-channels> + + <s-cat>direct messages</s-cat> + <s-directs> + </s-directs> + </s-body> + + <s-foot> + <s-avatar></s-avatar> + <s-user> + <s-name>???</s-name> + <s-status>it's... you!</s-status> + </s-user> + </s-foot> + </sidebar> + + <div class="main"> + <container></container> + <box> + <button class="btn btn-primary file-btn">๐</button> + <input type="text" placeholder="type a message..."> + <button class="send-btn">send โต</button> + </box> + </div> + + </div> + + <div class="modal-backdrop" id="modal-backdrop"> + <div class="modal" id="modal-el" role="dialog" aria-modal="true" tabindex="-1"></div> + </div> + + <script src="chat.ts"></script> + <script src="stuff.ts"></script> +</body> + +</html>
\ No newline at end of file diff --git a/client/public/stuff.ts b/client/public/stuff.ts new file mode 100644 index 0000000..5d7e882 --- /dev/null +++ b/client/public/stuff.ts @@ -0,0 +1,58 @@ +export const sidebar = document.getElementById('sidebar')! +export const backdrop = document.getElementById('sidebar-backdrop')! +export const toggle = document.getElementById('sidebar-toggle')! +export const close = document.getElementById('sidebar-close')! + +export const openSidebar = () => { + sidebar.classList.add('open') + backdrop.classList.add('open') + toggle.setAttribute('aria-expanded', 'true') + document.body.style.overflow = 'hidden' +} +export const closeSidebar = () => { + sidebar.classList.remove('open') + backdrop.classList.remove('open') + toggle.setAttribute('aria-expanded', 'false') + document.body.style.overflow = '' +} + +toggle.addEventListener('click', () => sidebar.classList.contains('open') ? closeSidebar() : openSidebar()) +close.addEventListener('click', closeSidebar) +backdrop.addEventListener('click', closeSidebar) +document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSidebar() }) +window.addEventListener('resize', () => { if (window.innerWidth > 680) closeSidebar() }) + +document.querySelectorAll('s-item').forEach(item => { + item.addEventListener('click', function () { + document.querySelectorAll('s-item').forEach(i => i.removeAttribute('active')) + //@ts-expect-error + this.setAttribute('active', '') + if (window.innerWidth <= 680) closeSidebar() + }) +}) + +export const showModal = () => { + document.getElementById('modal-backdrop')!.classList.add('modal-open') + document.getElementById('modal-el')!.focus() +} +export const hideModal = () => { document.getElementById('modal-backdrop')!.classList.remove('modal-open') } +export const setModalHTML = (html: string) => { + document.getElementById('modal-el')!.innerHTML = html + showModal() +} +export const getModal = () => document.getElementById('modal-el') + +// document.getElementById('modal-backdrop')!.addEventListener('click', e => { if (e.target === document.getElementById('modal-backdrop')) hideModal() }) + +export const createPillHTML = (label: string, html: string, fullWidth = false) => + `<pill${fullWidth ? ' full' : ''}><p>${label}</p><elm>${html}</elm></pill>` + +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('input[type="range"]').forEach(slider => { + const valEl = slider.parentElement?.querySelector('val') + if (valEl) { + valEl.textContent = (slider as any).value + slider.addEventListener('input', () => valEl.textContent = (slider as any).value) + } + }) +})
\ No newline at end of file diff --git a/client/public/style.css b/client/public/style.css new file mode 100644 index 0000000..f2cbd93 --- /dev/null +++ b/client/public/style.css @@ -0,0 +1,1164 @@ +:root { + --bg: #0c0b0a; + --surface: #141311; + --surface-raise: #1a1815; + --border: #221f1c; + --border-bright: #312d29; + --fg: #f5f0ea; + --fg-dim: #6e6760; + --fg-dimmer: #2e2a26; + --in-color: #ff2eb8; + --in-glow: rgba(255, 46, 184, .10); + --out-color: #39ff7a; + --out-glow: rgba(57, 255, 122, .10); + --all-color: #ffcc00; + --all-glow: rgba(255, 204, 0, .10); + --accent: #ff5533; + --radius: 6px; + --font-mono: 'Berkeley Mono', 'Courier New', monospace; + --font-display: 'Syne', sans-serif; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + background: var(--bg); + color: var(--fg); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.6; + height: 100%; +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +body::before { + content: ''; + position: fixed; + inset: 0; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.02'/%3E%3C/svg%3E"); + pointer-events: none; + z-index: 0; +} + +.header { + padding: 18px 24px 14px; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 10px; + border-bottom: 1px solid var(--border); + position: relative; + z-index: 400; +} + +.header-text { + font-family: var(--font-mono); + font-weight: 700; + font-size: 15px; + letter-spacing: .08em; + text-transform: lowercase; +} + +.header-badge { + margin-left: auto; + font-size: 10px; + color: var(--fg-dim); + letter-spacing: .12em; + display: flex; + align-items: center; + gap: 5px; +} + +.header-badge span { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; +} + +.dot-live { + background: #ff5533; + box-shadow: 0 0 7px #ff5533; + animation: pulse 2s ease-in-out infinite; +} + +.dot-connecting { + background: var(--all-color); + box-shadow: 0 0 7px var(--all-color); + animation: pulse .8s ease-in-out infinite; +} + +.dot-initializing { + background: var(--in-color); + box-shadow: 0 0 7px var(--in-color); + animation: pulse 1.2s ease-in-out infinite; +} + +.dot-disconnected { + background: #cc2233; + box-shadow: 0 0 7px #cc2233; +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1 + } + + 50% { + opacity: .3 + } +} + +#sidebar-toggle { + display: none; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + background: transparent; + border: none; + border-radius: var(--radius); + color: var(--fg-dim); + cursor: pointer; + flex-shrink: 0; + transition: color .15s, background .15s; +} + +#sidebar-toggle:hover { + background: rgba(255, 255, 255, .05); + color: var(--fg); +} + +#sidebar-toggle svg { + width: 13px; + height: 13px; + display: block; +} + +.layout { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; + position: relative; +} + +sidebar { + width: 22%; + min-width: 200px; + max-width: 280px; + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: hidden; + position: relative; + z-index: 503 !important; +} + +sidebar::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 85, 51, .22) 50%, transparent); + pointer-events: none; +} + +s-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 13px 14px 10px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + font-size: 10px; + letter-spacing: .16em; + text-transform: uppercase; + color: var(--fg-dim); +} + +s-head button { + display: none; + background: none; + border: 1px solid transparent; + border-radius: 4px; + color: var(--fg-dim); + font-size: 13px; + cursor: pointer; + padding: 2px 6px; + line-height: 1; + transition: color .15s, border-color .15s, background .15s; +} + +s-head button:hover { + color: var(--fg); + border-color: var(--border-bright); + background: rgba(255, 255, 255, .04); +} + +s-body { + display: flex; + flex-direction: column; + flex: 1; + overflow-y: auto; + padding: 10px 8px; + gap: 2px; + scrollbar-width: thin; + scrollbar-color: var(--border-bright) transparent; + padding-top: 20px; +} + +s-body::-webkit-scrollbar { + width: 3px; +} + +s-body::-webkit-scrollbar-thumb { + background: var(--border-bright); + border-radius: 2px; +} + +s-cat { + display: block; + font-size: 9px; + letter-spacing: .18em; + text-transform: uppercase; + color: var(--fg-dimmer); + padding: 10px 8px 3px; +} + +s-cat:first-child { + padding-top: 3px; +} + +s-item { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 10px; + border-radius: var(--radius); + border: 1px solid transparent; + cursor: pointer; + color: var(--fg-dim); + font-size: 12px; + transition: background .12s, border-color .12s, color .12s; + user-select: none; + position: relative; +} + +s-item::before { + font-size: 11px; + opacity: .55; + flex-shrink: 0; + width: 12px; + text-align: center; + transition: opacity .12s; +} + +s-item[channel]::before { + content: '#'; +} + +s-item[dm]::before { + content: '@'; +} + +s-item[archived]::before { + content: 'ยท'; +} + +s-item[badge]::after { + content: attr(badge); + margin-left: auto; + font-size: 9px; + padding: 1px 6px; + border-radius: 10px; + background: rgba(255, 85, 51, .15); + border: 1px solid rgba(255, 85, 51, .25); + color: var(--accent); + text-shadow: 0 0 6px rgba(255, 85, 51, .4); + flex-shrink: 0; + align-self: center; +} + +s-item:hover { + background: rgba(255, 255, 255, .03); + border-color: var(--border); + color: var(--fg); +} + +s-item:hover::before { + opacity: 1; +} + +s-item[active] { + background: rgba(255, 85, 51, .07); + border-color: rgba(255, 85, 51, .18); + color: var(--fg); +} + +s-item[active]::before { + opacity: 1; + color: var(--accent); + text-shadow: 0 0 8px rgba(255, 85, 51, .6); +} + +/* s-item[archived] { + opacity: .5; +} */ + +s-dot { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + flex-shrink: 0; + margin-left: auto; + margin-right: 7px; + align-self: center; +} + +s-dot[online] { + background: var(--out-color); + box-shadow: 0 0 5px rgba(57, 255, 122, .7); +} + +s-dot[away] { + background: var(--all-color); + box-shadow: 0 0 5px rgba(255, 204, 0, .7); +} + +s-dot[offline] { + background: var(--fg-dimmer); +} + +s-foot { + display: flex; + align-items: center; + gap: 9px; + padding: 10px 12px; + border-top: 1px solid var(--border); + flex-shrink: 0; +} + +s-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 5px; + background: linear-gradient(135deg, rgba(255, 85, 51, .3), rgba(255, 46, 184, .3)); + border: 1px solid rgba(255, 85, 51, .2); + flex-shrink: 0; + font-size: 11px; + color: var(--accent); + text-shadow: 0 0 8px rgba(255, 85, 51, .6); + + transform: scale(80%); +} + +s-user { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} + +s-name { + display: block; + font-size: 12px; + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +s-status { + display: block; + font-size: 10px; + color: var(--fg-dim); + letter-spacing: .06em; +} + +.main { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + overflow: hidden; +} + +container { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px; + scrollbar-width: thin; + scrollbar-color: var(--border-bright) transparent; +} + +container::-webkit-scrollbar { + width: 4px; +} + +container::-webkit-scrollbar-thumb { + background: var(--border-bright); + border-radius: 2px; +} + +box { + flex-shrink: 0; + padding: 12px 16px 16px; + border-top: 1px solid var(--border); + display: flex; + gap: 8px; + align-items: center; +} + +.msg-row { + display: flex; + align-items: flex-start; + gap: 8px; + align-self: flex-start; + max-width: 85%; + animation: slideIn .2s ease-out both; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(5px) + } + + to { + opacity: 1; + transform: translateY(0) + } +} + +m-in, +m-out, +m-all, +m-system { + display: block; + padding: 9px 13px; + border-radius: var(--radius); + font-size: 13px; + line-height: 1.55; + border: 1px solid transparent; + transition: background .15s, border-color .15s; +} + +m-in { + color: var(--in-color); + background: var(--in-glow); + border-color: rgba(255, 46, 184, .12); + text-shadow: 0 0 14px rgba(255, 46, 184, .55); +} + +m-in:hover { + background: rgba(255, 46, 184, .15); + border-color: rgba(255, 46, 184, .25); +} + +m-out { + color: var(--out-color); + background: var(--out-glow); + border-color: rgba(57, 255, 122, .12); + text-shadow: 0 0 14px rgba(57, 255, 122, .55); +} + +m-out:hover { + background: rgba(57, 255, 122, .15); + border-color: rgba(57, 255, 122, .25); +} + +m-all { + color: var(--all-color); + background: var(--all-glow); + border-color: rgba(255, 204, 0, .12); + border-style: dashed; + text-shadow: 0 0 14px rgba(255, 204, 0, .55); +} + +m-all:hover { + background: rgba(255, 204, 0, .15); + border-color: rgba(255, 204, 0, .28); +} + +m-system { + color: var(--fg-dim); + background: rgba(110, 103, 96, .07); + border-color: rgba(110, 103, 96, .10); +} + +m-system:hover { + background: rgba(110, 103, 96, .12); + border-color: rgba(110, 103, 96, .20); +} + +.msg-prefix { + opacity: .45; + font-size: 11px; + margin-right: 3px; +} + +blob { + display: block; + width: 38px; + height: 38px; + min-width: 38px; + border-radius: var(--radius); + /* border: 1px solid red; */ + overflow: hidden; + flex-shrink: 0; +} + +/* .msg-row:has(m-in) blob { + border-color: rgba(255, 46, 184, .15); +} + +.msg-row:has(m-out) blob { + border-color: rgba(57, 255, 122, .15); +} + +.msg-row:has(m-all) blob { + border-color: rgba(255, 204, 0, .15); + border-style: dashed; +} */ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: var(--font-mono); + font-weight: 700; + line-height: 1.2; + letter-spacing: .04em; + color: var(--fg); +} + +h1 { + font-size: 28px; + letter-spacing: .06em; + text-transform: lowercase; +} + +h2 { + font-size: 20px; + letter-spacing: .05em; + text-transform: lowercase; +} + +h3 { + font-size: 16px; +} + +h4 { + font-size: 14px; + font-family: var(--font-mono); + letter-spacing: .08em; + text-transform: uppercase; + color: var(--fg-dim); +} + +h5 { + font-size: 12px; + font-family: var(--font-mono); + letter-spacing: .10em; + text-transform: uppercase; + color: var(--fg-dim); +} + +h6 { + font-size: 11px; + font-family: var(--font-mono); + letter-spacing: .12em; + text-transform: uppercase; + color: var(--fg-dimmer); +} + +h1::after, +h2::after { + content: ''; + display: block; + margin-top: 6px; + height: 1px; + width: 2em; + background: var(--accent); + box-shadow: 0 0 8px var(--accent); +} + +strong, +b { + font-weight: 700; + color: var(--fg); + text-shadow: 0 0 10px rgba(245, 240, 234, .18); +} + +a { + color: var(--accent); + text-decoration: none; + border-bottom: 1px dashed rgba(255, 85, 51, .4); + transition: color .15s, border-color .15s, text-shadow .15s; +} + +a:hover { + color: #ff7755; + border-color: rgba(255, 119, 85, .7); + text-shadow: 0 0 10px rgba(255, 85, 51, .5); +} + +a:visited { + color: #cc4422; + border-color: rgba(204, 68, 34, .4); +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: .06em; + padding: 8px 16px; + border-radius: var(--radius); + border: 1px solid transparent; + cursor: pointer; + transition: background .15s, border-color .15s, transform .1s, box-shadow .15s; + white-space: nowrap; + user-select: none; + text-decoration: none; +} + +.btn:active { + transform: scale(.97); +} + +.btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.btn:disabled { + opacity: .32; + cursor: not-allowed; + pointer-events: none; + filter: grayscale(.5); +} + +.btn-primary { + background: rgba(255, 85, 51, .12); + border-color: rgba(255, 85, 51, .4); + color: #ff7755; + text-shadow: 0 0 8px rgba(255, 85, 51, .5); +} + +.btn-secondary { + background: rgba(49, 45, 41, .5); + border-color: var(--border-bright); + color: var(--fg-dim); +} + +.btn-danger { + background: rgba(255, 34, 68, .10); + border-color: rgba(255, 34, 68, .35); + color: #ff4466; + text-shadow: 0 0 8px rgba(255, 34, 68, .4); +} + +.btn-warn { + background: rgba(255, 170, 0, .10); + border-color: rgba(255, 170, 0, .35); + color: #ffbb33; + text-shadow: 0 0 8px rgba(255, 170, 0, .4); +} + +.btn-success { + background: rgba(57, 255, 122, .08); + border-color: rgba(57, 255, 122, .30); + color: var(--out-color); + text-shadow: 0 0 8px rgba(57, 255, 122, .4); +} + +.btn-primary:hover { + background: rgba(255, 85, 51, .22); + border-color: rgba(255, 85, 51, .7); + box-shadow: 0 0 12px rgba(255, 85, 51, .2); +} + +.btn-secondary:hover { + background: rgba(49, 45, 41, .9); + border-color: rgba(110, 103, 96, .5); + color: var(--fg); +} + +.btn-danger:hover { + background: rgba(255, 34, 68, .20); + border-color: rgba(255, 34, 68, .65); + box-shadow: 0 0 12px rgba(255, 34, 68, .2); +} + +.btn-warn:hover { + background: rgba(255, 170, 0, .20); + border-color: rgba(255, 170, 0, .60); + box-shadow: 0 0 12px rgba(255, 170, 0, .2); +} + +.btn-success:hover { + background: rgba(57, 255, 122, .16); + border-color: rgba(57, 255, 122, .55); + box-shadow: 0 0 12px rgba(57, 255, 122, .2); +} + +.send-btn { + background: rgba(255, 85, 51, .08); + border: 1px solid rgba(255, 85, 51, .28); + border-radius: var(--radius); + color: #ff7755; + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: .06em; + padding: 9px 16px; + cursor: pointer; + white-space: nowrap; + text-shadow: 0 0 8px rgba(255, 85, 51, .5); + transition: background .15s, border-color .15s, transform .1s; +} + +.send-btn:hover { + background: rgba(255, 85, 51, .16); + border-color: rgba(255, 85, 51, .5); +} + +.send-btn:active { + transform: scale(.97); +} + +input[type="text"], +input[type="email"], +input[type="password"], +input[type="search"], +input[type="number"] { + background: var(--surface); + border: 1px solid var(--border-bright); + border-radius: var(--radius); + color: var(--fg); + font-family: var(--font-mono); + font-size: 13px; + padding: 9px 13px; + outline: none; + width: 100%; + transition: border-color .15s, box-shadow .15s, background .15s; +} + +input::placeholder { + color: var(--fg-dimmer); +} + +input:focus { + border-color: rgba(255, 85, 51, .45); + box-shadow: 0 0 0 3px rgba(255, 85, 51, .07); + background: var(--surface-raise); +} + +input:disabled { + opacity: .35; + cursor: not-allowed; +} + +textarea { + background: var(--surface); + border: 1px solid var(--border-bright); + border-radius: var(--radius); + color: var(--fg); + font-family: var(--font-mono); + font-size: 13px; + padding: 9px 13px; + outline: none; + width: 100%; + resize: none; + min-height: 80px; + transition: border-color .15s, box-shadow .15s, background .15s; +} + +textarea::placeholder { + color: var(--fg-dimmer); +} + +textarea:focus { + border-color: rgba(255, 85, 51, .45); + box-shadow: 0 0 0 3px rgba(255, 85, 51, .07); + background: var(--surface-raise); +} + +textarea:disabled { + opacity: .35; + cursor: not-allowed; +} + +field { + display: flex; + flex-direction: column; + gap: 5px; +} + +field>label { + font-size: 10px; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--fg-dim); + cursor: default; +} + +label:has(input[type="radio"]), +label:has(input[type="checkbox"]) { + display: inline-flex; + align-items: center; + gap: 9px; + cursor: pointer; + font-size: 13px; + color: var(--fg-dim); + user-select: none; + transition: color .15s; +} + +label:has(input[type="radio"]):hover, +label:has(input[type="checkbox"]):hover, +label:has(input[type="radio"]:checked), +label:has(input[type="checkbox"]:checked) { + color: var(--fg); +} + +label>input[type="radio"], +label>input[type="checkbox"] { + display: none; +} + +indicator { + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid var(--border-bright); + background: var(--surface); + flex-shrink: 0; + transition: border-color .15s, box-shadow .15s; +} + +indicator::after { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 6px rgba(255, 85, 51, .8); + opacity: 0; + transform: scale(0); + transition: opacity .15s, transform .15s; +} + +label:has(input[type="checkbox"]) indicator { + border-radius: 3px; +} + +label:has(input[type="checkbox"]) indicator::after { + width: 5px; + height: 5px; + border-radius: 1px; +} + +label:has(input[type="radio"]:checked) indicator, +label:has(input[type="checkbox"]:checked) indicator { + border-color: rgba(255, 85, 51, .6); + box-shadow: 0 0 0 3px rgba(255, 85, 51, .08); +} + +label:has(input[type="radio"]:checked) indicator::after, +label:has(input[type="checkbox"]:checked) indicator::after { + opacity: 1; + transform: scale(1); +} + +row { + display: flex; + align-items: center; + gap: 10px; +} + +val { + min-width: 32px; + text-align: right; + font-size: 11px; + color: var(--accent); + text-shadow: 0 0 6px rgba(255, 85, 51, .5); + letter-spacing: .06em; + flex-shrink: 0; +} + +input[type="range"] { + -webkit-appearance: none; + appearance: none; + flex: 1; + height: 4px; + border-radius: 2px; + background: var(--border-bright); + outline: none; + cursor: pointer; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent); + border: 1px solid rgba(255, 85, 51, .5); + box-shadow: 0 0 8px rgba(255, 85, 51, .55); + transition: box-shadow .15s, transform .1s; +} + +input[type="range"]::-webkit-slider-thumb:active { + transform: scale(1.2); +} + +input[type="range"]::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent); + border: 1px solid rgba(255, 85, 51, .5); + box-shadow: 0 0 8px rgba(255, 85, 51, .55); + cursor: pointer; +} + +pill { + display: inline-flex; + align-items: stretch; + border: 1px solid var(--border-bright); + border-radius: 40px; + overflow: hidden; + background: var(--surface); + font-size: 12px; + transition: border-color .15s; +} + +pill[full] { + display: flex; + width: 100%; +} + +pill:focus-within { + border-color: rgba(255, 85, 51, .4); + box-shadow: 0 0 0 3px rgba(255, 85, 51, .07); +} + +pill>p { + display: flex; + align-items: center; + padding: 5px 12px 5px 13px; + color: var(--fg-dim); + letter-spacing: .08em; + font-size: 11px; + text-transform: uppercase; + border-right: 1px solid var(--border-bright); + white-space: nowrap; + background: rgba(49, 45, 41, .4); + margin: 0; +} + +pill>elm { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 13px 5px 11px; + color: var(--fg); + flex: 1; + min-width: 0; +} + +pill>elm input, +pill>elm select { + border: none; + background: transparent; + box-shadow: none; + padding: 0; + margin: 0; + font-size: 12px; + color: var(--fg); + width: 100%; + min-width: 0; + outline: none; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(12, 11, 10, .82); + backdrop-filter: blur(4px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity .2s ease; +} + +.modal-backdrop.modal-open { + opacity: 1; + pointer-events: all; +} + +.modal { + background: var(--surface); + border: 1px solid var(--border-bright); + border-radius: 8px; + min-width: 340px; + max-width: min(560px, calc(100vw - 40px)); + max-height: calc(100vh - 80px); + overflow-y: auto; + position: relative; + box-shadow: 0 0 0 1px rgba(255, 255, 255, .03), 0 24px 60px rgba(0, 0, 0, .6); + transform: translateY(10px) scale(.98); + transition: transform .22s ease; + scrollbar-width: thin; + scrollbar-color: var(--border-bright) transparent; + + padding: 10px; +} + +.modal-backdrop.modal-open .modal { + transform: translateY(0) scale(1); +} + +.modal::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent 10%, rgba(255, 85, 51, .3) 50%, transparent 90%); + pointer-events: none; +} + +.modal-header { + display: flex; + align-items: center; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + gap: 8px; +} + +.modal-title { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 700; + letter-spacing: .06em; + text-transform: lowercase; + flex: 1; +} + +.modal-close { + background: none; + border: 1px solid transparent; + border-radius: 4px; + color: var(--fg-dim); + font-size: 14px; + cursor: pointer; + padding: 2px 6px; + line-height: 1; + transition: color .15s, border-color .15s, background .15s; +} + +.modal-close:hover { + color: var(--fg); + border-color: var(--border-bright); + background: rgba(255, 255, 255, .04); +} + +.modal-body { + padding: 18px; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 18px; + border-top: 1px solid var(--border); +} + +#sidebar-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(12, 11, 10, .8); + z-index: 500; + opacity: 0; + pointer-events: none; + transition: opacity .25s ease; + backdrop-filter: blur(4px); +} + +#sidebar-backdrop.open { + opacity: 1; + pointer-events: all; +} + +@media (max-width: 680px) { + #sidebar-toggle { + display: inline-flex; + } + + s-head button { + display: block; + } + + sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 72% !important; + max-width: 320px; + transform: translateX(-110%); + transition: transform .28s cubic-bezier(.4, 0, .2, 1); + box-shadow: 8px 0 40px rgba(0, 0, 0, .5); + border-right: 1px solid var(--border-bright); + } + + sidebar.open { + transform: translateX(0); + } + + #sidebar-backdrop { + display: block; + } +}
\ No newline at end of file diff --git a/client/server.ts b/client/server.ts new file mode 100644 index 0000000..f5e5d5a --- /dev/null +++ b/client/server.ts @@ -0,0 +1,8 @@ +import index from './public/index.html' + +Bun.serve({ + port: 3000, + routes: { + '/': index + } +})
\ No newline at end of file diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..f8b2c4d --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "dom"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} |
