1400 lines
53 KiB
Rust
1400 lines
53 KiB
Rust
use axum::{
|
|
http::header,
|
|
response::{Html, IntoResponse},
|
|
routing::get,
|
|
Router,
|
|
};
|
|
|
|
use crate::state::AppState;
|
|
|
|
pub fn routes() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/", get(web_ui))
|
|
.route("/wasm/warzone_wasm.js", get(wasm_js))
|
|
.route("/wasm/warzone_wasm_bg.wasm", get(wasm_binary))
|
|
.route("/manifest.json", get(pwa_manifest))
|
|
.route("/sw.js", get(service_worker))
|
|
.route("/icon.svg", get(pwa_icon))
|
|
}
|
|
|
|
async fn web_ui() -> Html<&'static str> {
|
|
Html(WEB_HTML)
|
|
}
|
|
|
|
async fn wasm_js() -> impl IntoResponse {
|
|
(
|
|
[(header::CONTENT_TYPE, "application/javascript")],
|
|
include_str!("../../../../wasm-pkg/warzone_wasm.js"),
|
|
)
|
|
}
|
|
|
|
async fn wasm_binary() -> impl IntoResponse {
|
|
(
|
|
[(header::CONTENT_TYPE, "application/wasm")],
|
|
include_bytes!("../../../../wasm-pkg/warzone_wasm_bg.wasm").as_slice(),
|
|
)
|
|
}
|
|
|
|
async fn pwa_manifest() -> impl IntoResponse {
|
|
([(header::CONTENT_TYPE, "application/manifest+json")], r##"{
|
|
"name": "Warzone Messenger",
|
|
"short_name": "Warzone",
|
|
"description": "End-to-end encrypted messenger",
|
|
"start_url": "/",
|
|
"display": "standalone",
|
|
"background_color": "#0a0a1a",
|
|
"theme_color": "#0a0a1a",
|
|
"icons": [{"src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"}]
|
|
}"##)
|
|
}
|
|
|
|
async fn service_worker() -> impl IntoResponse {
|
|
([(header::CONTENT_TYPE, "application/javascript")], r##"
|
|
const CACHE = 'wz-v14';
|
|
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
|
|
|
|
self.addEventListener('install', e => {
|
|
e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)));
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener('activate', e => {
|
|
e.waitUntil(caches.keys().then(keys =>
|
|
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
|
|
));
|
|
e.waitUntil(clients.claim());
|
|
});
|
|
|
|
self.addEventListener('fetch', e => {
|
|
const url = new URL(e.request.url);
|
|
// API calls: network only
|
|
if (url.pathname.startsWith('/v1/')) return;
|
|
// WS: skip
|
|
if (url.protocol === 'ws:' || url.protocol === 'wss:') return;
|
|
// Network first, cache fallback (ensures updates are picked up immediately)
|
|
e.respondWith(
|
|
fetch(e.request).then(resp => {
|
|
if (resp.ok && SHELL.includes(url.pathname)) {
|
|
const clone = resp.clone();
|
|
caches.open(CACHE).then(c => c.put(e.request, clone));
|
|
}
|
|
return resp;
|
|
}).catch(() => caches.match(e.request).then(cached => {
|
|
if (cached) return cached;
|
|
if (e.request.mode === 'navigate') return caches.match('/');
|
|
}))
|
|
);
|
|
});
|
|
"##)
|
|
}
|
|
|
|
async fn pwa_icon() -> impl IntoResponse {
|
|
([(header::CONTENT_TYPE, "image/svg+xml")], r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
<rect width="512" height="512" rx="100" fill="#0a0a1a"/>
|
|
<path d="M128 140h256c22 0 40 18 40 40v152c0 22-18 40-40 40H210l-70 52v-52h-12c-22 0-40-18-40-40V180c0-22 18-40 40-40z" fill="#e94560"/>
|
|
<circle cx="200" cy="256" r="18" fill="#0a0a1a"/>
|
|
<circle cx="256" cy="256" r="18" fill="#0a0a1a"/>
|
|
<circle cx="312" cy="256" r="18" fill="#0a0a1a"/>
|
|
<rect x="180" y="340" width="30" height="8" rx="4" fill="#4ade80"/>
|
|
<rect x="220" y="340" width="30" height="8" rx="4" fill="#4ade80"/>
|
|
</svg>"##)
|
|
}
|
|
|
|
const WEB_HTML: &str = r##"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
|
|
<meta name="theme-color" content="#0a0a1a">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<meta name="apple-mobile-web-app-title" content="Warzone">
|
|
<meta name="mobile-web-app-capable" content="yes">
|
|
<link rel="manifest" href="/manifest.json">
|
|
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
|
<link rel="apple-touch-icon" href="/icon.svg">
|
|
<title>Warzone</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { height: 100%; overflow: hidden; }
|
|
body { background: #0a0a1a; color: #c8d6e5; font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
|
|
display: flex; flex-direction: column; height: 100dvh; font-size: 14px; }
|
|
|
|
/* Setup screen */
|
|
.screen { display: none; flex-direction: column; flex: 1; }
|
|
.screen.active { display: flex; }
|
|
|
|
#setup { align-items: center; justify-content: center; padding: 20px; }
|
|
#setup h1 { color: #e94560; font-size: 1.8em; margin-bottom: 4px; }
|
|
#setup .sub { color: #555; margin-bottom: 24px; font-size: 0.8em; }
|
|
.fp-display { background: #111; border: 1px solid #333; padding: 12px 20px; border-radius: 6px;
|
|
font-size: 1.1em; color: #4ade80; letter-spacing: 1px; margin: 8px 0; font-family: monospace;
|
|
user-select: all; cursor: pointer; }
|
|
.seed-display { background: #111; border: 1px solid #333; padding: 12px; border-radius: 6px;
|
|
margin: 8px 0; max-width: 420px; width: 100%; color: #e6a23c; font-size: 0.8em;
|
|
line-height: 1.8; text-align: center; user-select: all; word-break: break-all; }
|
|
.warn { color: #e94560; font-size: 0.75em; margin: 6px 0; }
|
|
.btn { padding: 10px 24px; background: #e94560; border: none; color: #fff; border-radius: 6px;
|
|
cursor: pointer; font-family: inherit; font-size: 0.9em; margin: 4px; }
|
|
.btn:hover { background: #c73e54; }
|
|
.btn-alt { background: #1a1a3e; border: 1px solid #444; }
|
|
.btn-alt:hover { background: #252550; }
|
|
#recover-area { display: none; margin-top: 12px; max-width: 420px; width: 100%; }
|
|
#recover-area textarea { width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333;
|
|
color: #c8d6e5; border-radius: 6px; font-family: inherit; min-height: 50px;
|
|
resize: none; margin-bottom: 8px; font-size: 14px; }
|
|
|
|
/* Chat screen */
|
|
#chat-header { padding: 6px 10px; background: #111; border-bottom: 1px solid #222;
|
|
display: flex; align-items: center; gap: 8px; font-size: 0.8em; flex-wrap: wrap; }
|
|
#chat-header .tag { padding: 1px 6px; border-radius: 3px; font-size: 0.85em; }
|
|
.tag-fp { background: #0a2e0a; color: #4ade80; }
|
|
.tag-peer { background: #2e2e0a; color: #e6a23c; }
|
|
.tag-server { color: #444; }
|
|
#chat-header input { background: #1a1a2e; border: 1px solid #333; color: #e6a23c; padding: 2px 6px;
|
|
border-radius: 3px; font-family: inherit; font-size: 0.85em; width: 280px; }
|
|
|
|
#messages { flex: 1; overflow-y: scroll; padding: 8px 10px; -webkit-overflow-scrolling: touch; min-height: 0; }
|
|
.msg { padding: 2px 0; font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word; }
|
|
.msg code { background: #1a1a3e; padding: 1px 4px; border-radius: 3px; font-size: 0.95em; color: #4fc3f7; }
|
|
.msg pre { background: #0d0d20; padding: 8px; border-radius: 4px; margin: 4px 0; overflow-x: auto; border: 1px solid #222; }
|
|
.msg pre code { background: none; padding: 0; }
|
|
.msg strong, .msg b { color: #fff; }
|
|
.msg em, .msg i { color: #e6a23c; }
|
|
.msg a { color: #4fc3f7; }
|
|
.msg blockquote { border-left: 3px solid #444; padding-left: 8px; color: #888; margin: 4px 0; }
|
|
.msg ul, .msg ol { padding-left: 20px; margin: 4px 0; }
|
|
.msg h1, .msg h2, .msg h3 { color: #fff; margin: 6px 0 2px; }
|
|
.msg h1 { font-size: 1.2em; } .msg h2 { font-size: 1.1em; } .msg h3 { font-size: 1em; }
|
|
.msg .ts { color: #333; margin-right: 4px; }
|
|
.msg .from-self { color: #4ade80; font-weight: bold; }
|
|
.msg .from-sys { color: #5e9ca0; font-style: italic; }
|
|
.msg .lock { color: #ff6b9d; }
|
|
|
|
#bottom { display: flex; padding: 6px; gap: 6px; border-top: 1px solid #222; background: #111;
|
|
align-items: flex-end; }
|
|
#msg-input { flex: 1; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #c8d6e5;
|
|
border-radius: 20px; font-family: inherit; font-size: 14px; resize: none;
|
|
min-height: 40px; max-height: 120px; line-height: 1.4; }
|
|
#send-btn { padding: 10px 16px; background: #e94560; border: none; color: #fff; border-radius: 20px;
|
|
cursor: pointer; font-size: 14px; min-height: 40px; }
|
|
#send-btn:hover { background: #c73e54; }
|
|
|
|
.addr { color: #4fc3f7; cursor: pointer; text-decoration: underline; }
|
|
.addr:hover { color: #81d4fa; }
|
|
|
|
@media (max-width: 500px) {
|
|
.msg { font-size: 0.8em; }
|
|
#chat-header input { width: 180px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Setup screen -->
|
|
<div id="setup" class="screen active">
|
|
<h1>WARZONE</h1>
|
|
<div class="sub">end-to-end encrypted messenger</div>
|
|
|
|
<div id="gen-section">
|
|
<button class="btn" id="btn-generate">Generate Identity</button>
|
|
<button class="btn btn-alt" id="btn-show-recover">Recover</button>
|
|
<div id="recover-area">
|
|
<textarea id="recover-input" placeholder="Paste your hex seed (64 chars)..." rows="2"></textarea>
|
|
<button class="btn" id="btn-recover">Recover Identity</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="id-section" style="display:none">
|
|
<div>Your fingerprint:</div>
|
|
<div class="fp-display" id="my-fp" title="Click to copy"></div>
|
|
<div class="seed-display" id="my-seed"></div>
|
|
<div class="warn">SAVE YOUR SEED — only way to recover your identity</div>
|
|
<br>
|
|
<button class="btn" id="btn-enter">Enter Chat</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chat screen -->
|
|
<div id="chat" class="screen">
|
|
<div id="chat-header">
|
|
<span class="tag tag-fp" id="hdr-fp" style="cursor:pointer" title="Click to copy"></span>
|
|
<span id="hdr-eth" style="display:none"></span>
|
|
<span>→</span>
|
|
<input id="peer-input" placeholder="Paste peer fingerprint..." autocomplete="off">
|
|
<span class="tag-server" id="hdr-server"></span>
|
|
</div>
|
|
<div id="messages"></div>
|
|
<div id="bottom">
|
|
<label id="file-btn" title="Send file" style="padding:10px;background:#1a1a2e;border:1px solid #333;color:#c8d6e5;border-radius:50%;cursor:pointer;min-width:40px;min-height:40px;text-align:center;line-height:20px;font-size:1.1em">📎<input type="file" id="file-input" style="display:none"></label>
|
|
<textarea id="msg-input" placeholder="Message... (Enter to send)" rows="1"></textarea>
|
|
<button id="send-btn">▶</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="module">
|
|
import init, { WasmIdentity, WasmSession, decrypt_wire_message, self_test, debug_bundle_info, create_receipt } from '/wasm/warzone_wasm.js';
|
|
|
|
const SERVER = window.location.origin;
|
|
const $messages = document.getElementById('messages');
|
|
const $input = document.getElementById('msg-input');
|
|
const $peerInput = document.getElementById('peer-input');
|
|
|
|
// ── State ──
|
|
let wasmIdentity = null; // WasmIdentity from WASM
|
|
let myFingerprint = '';
|
|
let myEthAddress = '';
|
|
let mySeedHex = '';
|
|
let sessions = {}; // peerFP -> { session: WasmSession, data: base64 }
|
|
let peerBundles = {}; // peerFP -> bundle bytes
|
|
let pollTimer = null;
|
|
let ws = null; // WebSocket connection
|
|
let wasmReady = false;
|
|
|
|
const VERSION = '0.0.32';
|
|
let DEBUG = true; // toggle with /debug command
|
|
|
|
// ── Receipt tracking ──
|
|
let sentMsgReceipts = {}; // messageId -> { status: 'sent'|'delivered'|'read', el: DOM element }
|
|
|
|
function receiptIndicator(status) {
|
|
switch (status) {
|
|
case 'read': return '\u2713\u2713';
|
|
case 'delivered': return '\u2713\u2713';
|
|
case 'sent': default: return '\u2713';
|
|
}
|
|
}
|
|
|
|
function receiptColor(status) {
|
|
switch (status) {
|
|
case 'read': return '#67c7eb';
|
|
case 'delivered': return '#ccc';
|
|
case 'sent': default: return '#555';
|
|
}
|
|
}
|
|
|
|
function updateReceiptDisplay(messageId, status) {
|
|
const entry = sentMsgReceipts[messageId];
|
|
if (!entry) return;
|
|
// Only upgrade status: sent -> delivered -> read
|
|
const order = { sent: 0, delivered: 1, read: 2 };
|
|
if ((order[status] || 0) <= (order[entry.status] || 0)) return;
|
|
entry.status = status;
|
|
if (entry.el) {
|
|
entry.el.textContent = ' ' + receiptIndicator(status);
|
|
entry.el.style.color = receiptColor(status);
|
|
}
|
|
}
|
|
|
|
function sendReceipt(toPeerFP, messageId, receiptType) {
|
|
try {
|
|
const receiptBytes = create_receipt(normFP(myFingerprint), messageId, receiptType);
|
|
const fp = normFP(toPeerFP);
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ to: fp, from: normFP(myFingerprint), message: Array.from(receiptBytes) }));
|
|
} else {
|
|
fetch(SERVER + '/v1/messages/send', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ to: fp, from: normFP(myFingerprint), message: Array.from(receiptBytes) })
|
|
});
|
|
}
|
|
dbg('Sent', receiptType, 'receipt for', messageId, 'to', fp);
|
|
} catch(e) {
|
|
dbg('Failed to send receipt:', e);
|
|
}
|
|
}
|
|
|
|
function dbg(...args) {
|
|
if (DEBUG) console.log('[WZ]', ...args);
|
|
}
|
|
|
|
function normFP(fp) {
|
|
return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
|
|
}
|
|
|
|
function makeAddressClickable(text) {
|
|
// Match fingerprint format: xxxx:xxxx:xxxx:xxxx... (at least 4 groups)
|
|
text = text.replace(/([0-9a-f]{4}(?::[0-9a-f]{4}){3,})/gi, function(match) {
|
|
const fp = match.replace(/:/g, '');
|
|
return '<span class="addr" data-addr="' + fp + '" title="Click to message">' + match + '</span>';
|
|
});
|
|
// Match ETH addresses: 0x followed by 40 hex chars
|
|
text = text.replace(/(0x[0-9a-fA-F]{40})/g, function(match) {
|
|
return '<span class="addr" data-addr="' + match + '" title="Click to message">' + match + '</span>';
|
|
});
|
|
return text;
|
|
}
|
|
|
|
function handleAddrClick(addr) {
|
|
const input = document.getElementById('msg-input');
|
|
if (input && input.value.trim().length > 0) {
|
|
navigator.clipboard.writeText(addr).then(() => {
|
|
addSys('Copied: ' + addr);
|
|
});
|
|
} else {
|
|
$peerInput.value = addr;
|
|
currentGroup = null;
|
|
localStorage.setItem('wz-peer', addr);
|
|
addSys('Peer set to ' + addr.slice(0,16) + '...');
|
|
}
|
|
}
|
|
|
|
// ── WASM-based crypto (same as CLI: X25519 + ChaCha20 + Double Ratchet) ──
|
|
|
|
async function initWasm() {
|
|
await init('/wasm/warzone_wasm_bg.wasm');
|
|
wasmReady = true;
|
|
}
|
|
|
|
let mySpkSecretHex = '';
|
|
|
|
function initIdentityFromSeed(hexSeed) {
|
|
wasmIdentity = WasmIdentity.from_hex_seed(hexSeed);
|
|
myFingerprint = wasmIdentity.fingerprint();
|
|
mySeedHex = wasmIdentity.seed_hex();
|
|
|
|
// Restore or generate SPK secret
|
|
const savedSpk = localStorage.getItem('wz-spk');
|
|
if (savedSpk) {
|
|
wasmIdentity.set_spk_secret_hex(savedSpk);
|
|
mySpkSecretHex = savedSpk;
|
|
dbg('Restored SPK secret from localStorage');
|
|
} else {
|
|
mySpkSecretHex = wasmIdentity.spk_secret_hex();
|
|
localStorage.setItem('wz-spk', mySpkSecretHex);
|
|
dbg('Generated new SPK secret');
|
|
}
|
|
|
|
localStorage.setItem('wz-seed', mySeedHex);
|
|
localStorage.setItem('wz-fp', myFingerprint);
|
|
}
|
|
|
|
function generateNewIdentity() {
|
|
wasmIdentity = new WasmIdentity();
|
|
myFingerprint = wasmIdentity.fingerprint();
|
|
mySeedHex = wasmIdentity.seed_hex();
|
|
mySpkSecretHex = wasmIdentity.spk_secret_hex();
|
|
localStorage.setItem('wz-seed', mySeedHex);
|
|
localStorage.setItem('wz-fp', myFingerprint);
|
|
localStorage.setItem('wz-spk', mySpkSecretHex);
|
|
dbg('New identity, SPK secret stored');
|
|
}
|
|
|
|
function loadSavedIdentity() {
|
|
const saved = localStorage.getItem('wz-seed');
|
|
if (!saved) { dbg('No saved seed'); return false; }
|
|
try {
|
|
initIdentityFromSeed(saved);
|
|
dbg('Loaded identity:', myFingerprint, 'has SPK:', !!mySpkSecretHex);
|
|
return true;
|
|
} catch(e) {
|
|
dbg('Failed to load identity:', e);
|
|
localStorage.removeItem('wz-seed');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function registerKey() {
|
|
const fp = normFP(myFingerprint);
|
|
const bundleBytes = wasmIdentity.bundle_bytes();
|
|
myEthAddress = wasmIdentity.eth_address();
|
|
dbg('Registering key, fp:', fp, 'bundle size:', bundleBytes.length, 'eth:', myEthAddress);
|
|
await fetch(SERVER + '/v1/keys/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fingerprint: fp, bundle: Array.from(bundleBytes), eth_address: myEthAddress })
|
|
});
|
|
dbg('Key registered');
|
|
}
|
|
|
|
async function fetchPeerBundle(peerFP) {
|
|
const fp = normFP(peerFP);
|
|
if (peerBundles[fp]) return peerBundles[fp];
|
|
|
|
const resp = await fetch(SERVER + '/v1/keys/' + fp);
|
|
if (!resp.ok) throw new Error('Peer not registered');
|
|
const data = await resp.json();
|
|
const bytes = Uint8Array.from(atob(data.bundle), c => c.charCodeAt(0));
|
|
peerBundles[fp] = bytes;
|
|
return bytes;
|
|
}
|
|
|
|
function generateMsgId() {
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
const r = Math.random() * 16 | 0;
|
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
});
|
|
}
|
|
|
|
async function sendEncrypted(peerFP, plaintext) {
|
|
const fp = normFP(peerFP);
|
|
dbg('sendEncrypted to:', fp, 'text length:', plaintext.length);
|
|
|
|
const bundleBytes = await fetchPeerBundle(fp);
|
|
dbg('Got peer bundle, size:', bundleBytes.length);
|
|
|
|
const msgId = generateMsgId();
|
|
let wireBytes;
|
|
if (sessions[fp]) {
|
|
dbg('Using existing session for', fp);
|
|
try {
|
|
const sess = WasmSession.restore(sessions[fp].data);
|
|
wireBytes = sess.encrypt_with_id(wasmIdentity, plaintext, msgId);
|
|
sessions[fp].data = sess.save();
|
|
} catch(e) {
|
|
dbg('Existing session encrypt failed, creating new:', e.message);
|
|
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
|
|
wireBytes = sess.encrypt_key_exchange_with_id(wasmIdentity, bundleBytes, plaintext, msgId);
|
|
sessions[fp] = { data: sess.save() };
|
|
}
|
|
} else {
|
|
dbg('New session (X3DH) for', fp);
|
|
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
|
|
wireBytes = sess.encrypt_key_exchange_with_id(wasmIdentity, bundleBytes, plaintext, msgId);
|
|
sessions[fp] = { data: sess.save() };
|
|
}
|
|
|
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
|
));
|
|
|
|
dbg('Sending wire message, size:', wireBytes.length, 'id:', msgId);
|
|
|
|
// Prefer WebSocket, fall back to HTTP
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
to: fp,
|
|
from: normFP(myFingerprint),
|
|
message: Array.from(wireBytes)
|
|
}));
|
|
dbg('Sent via WebSocket');
|
|
} else {
|
|
await fetch(SERVER + '/v1/messages/send', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
to: fp,
|
|
from: normFP(myFingerprint),
|
|
message: Array.from(wireBytes)
|
|
})
|
|
});
|
|
dbg('Sent via HTTP (WS not connected)');
|
|
}
|
|
|
|
return msgId;
|
|
}
|
|
|
|
// URL deep links: /message/@alias, /message/0xABC, /group/#ops
|
|
function handleDeepLink() {
|
|
const path = window.location.pathname;
|
|
if (path.startsWith('/message/')) {
|
|
const target = decodeURIComponent(path.slice(9));
|
|
if (target) {
|
|
setTimeout(() => {
|
|
$peerInput.value = target;
|
|
if (target.startsWith('@')) {
|
|
fetch(SERVER + '/v1/alias/resolve/' + target.slice(1)).then(r => r.json()).then(data => {
|
|
if (!data.error) {
|
|
$peerInput.value = data.fingerprint;
|
|
currentGroup = null;
|
|
localStorage.setItem('wz-peer', data.fingerprint);
|
|
addSys('Deep link: peer set to ' + target + ' (' + data.fingerprint.slice(0,16) + '...)');
|
|
} else {
|
|
addSys('Deep link: unknown alias ' + target);
|
|
}
|
|
});
|
|
} else {
|
|
currentGroup = null;
|
|
localStorage.setItem('wz-peer', target);
|
|
addSys('Deep link: peer set to ' + target.slice(0,16) + '...');
|
|
}
|
|
}, 500);
|
|
}
|
|
} else if (path.startsWith('/group/')) {
|
|
let group = decodeURIComponent(path.slice(7));
|
|
if (group.startsWith('#')) group = group.slice(1);
|
|
if (group) {
|
|
setTimeout(() => {
|
|
groupSwitch(group);
|
|
}, 500);
|
|
}
|
|
}
|
|
}
|
|
|
|
function connectWebSocket() {
|
|
const fp = normFP(myFingerprint);
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = proto + '//' + location.host + '/v1/ws/' + fp;
|
|
dbg('Connecting WebSocket:', wsUrl);
|
|
|
|
ws = new WebSocket(wsUrl);
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.onopen = () => {
|
|
dbg('WebSocket connected');
|
|
addSys('Real-time connection established');
|
|
handleDeepLink();
|
|
};
|
|
|
|
ws.onmessage = async (event) => {
|
|
if (typeof event.data === 'string') {
|
|
// Text frame — could be a bot message or missed call notification
|
|
try {
|
|
const json = JSON.parse(event.data);
|
|
if (json.type === 'missed_call') {
|
|
addSys('Missed call from ' + (json.data?.caller_fp || 'unknown'));
|
|
return;
|
|
}
|
|
if (json.type === 'bot_message') {
|
|
const botName = json.from_name || json.from || 'bot';
|
|
let msgText = json.text || '';
|
|
if (json.reply_markup && json.reply_markup.inline_keyboard) {
|
|
msgText += '\\n';
|
|
for (const row of json.reply_markup.inline_keyboard) {
|
|
for (const btn of row) {
|
|
msgText += ' [' + btn.text + '] ';
|
|
}
|
|
msgText += '\\n';
|
|
}
|
|
}
|
|
const useHtml = json.parse_mode === 'HTML';
|
|
addMsg('@' + botName, msgText, false, null, useHtml);
|
|
lastDmPeer = json.from ? normFP(json.from) : '';
|
|
return;
|
|
}
|
|
if (json.type === 'bot_edit') {
|
|
addSys('[bot updated: ' + (json.text || '') + ']');
|
|
return;
|
|
}
|
|
if (json.type === 'bot_document') {
|
|
addMsg('@' + (json.from || 'bot'), '[Document: ' + json.document + ']', false);
|
|
return;
|
|
}
|
|
} catch(e) {}
|
|
// If not JSON or unrecognized, try treating as binary
|
|
const bytes = new TextEncoder().encode(event.data);
|
|
dbg('WS text frame treated as bytes,', bytes.length, 'bytes');
|
|
await handleIncomingMessage(bytes);
|
|
return;
|
|
}
|
|
const bytes = new Uint8Array(event.data);
|
|
dbg('WS received', bytes.length, 'bytes');
|
|
await handleIncomingMessage(bytes);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
dbg('WebSocket closed, reconnecting in 3s...');
|
|
addSys('Connection lost, reconnecting...');
|
|
setTimeout(connectWebSocket, 3000);
|
|
};
|
|
|
|
ws.onerror = (e) => {
|
|
dbg('WebSocket error:', e);
|
|
};
|
|
}
|
|
|
|
async function handleIncomingMessage(bytes) {
|
|
dbg('Processing message,', bytes.length, 'bytes, sessions:', Object.keys(sessions));
|
|
|
|
// Quick check: try to parse as Receipt first (no session needed, no decrypt)
|
|
try {
|
|
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, null);
|
|
const result = JSON.parse(resultStr);
|
|
|
|
if (result.type === 'receipt') {
|
|
dbg('Received', result.receipt_type, 'receipt for', result.message_id, 'from', result.sender);
|
|
updateReceiptDisplay(result.message_id, result.receipt_type);
|
|
return;
|
|
}
|
|
if (result.type === 'file_header') {
|
|
handleFileHeader(result);
|
|
return;
|
|
}
|
|
if (result.type === 'file_chunk') {
|
|
handleFileChunk(result);
|
|
return;
|
|
}
|
|
|
|
// It was a KeyExchange
|
|
dbg('Decrypted (KeyExchange) from:', result.sender);
|
|
|
|
const senderFP = normFP(result.sender);
|
|
sessions[senderFP] = { data: result.session_data };
|
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
|
));
|
|
|
|
let fromLabel = result.sender.slice(0, 19);
|
|
try {
|
|
const ar = await fetch(SERVER + '/v1/resolve/' + senderFP);
|
|
const ad = await ar.json();
|
|
if (ad.eth_address) fromLabel = ad.eth_address.slice(0, 12) + '...';
|
|
// Alias overrides ETH
|
|
const aw = await fetch(SERVER + '/v1/alias/whois/' + senderFP);
|
|
const adata = await aw.json();
|
|
if (adata.alias) fromLabel = '@' + adata.alias;
|
|
} catch(e) {}
|
|
|
|
addMsg(fromLabel, result.text, false);
|
|
if (result.message_id) sendReceipt(result.sender, result.message_id, 'delivered');
|
|
lastDmPeer = normFP(result.sender);
|
|
return;
|
|
} catch(e) {
|
|
dbg('KeyExchange/Receipt parse failed:', e.message || e);
|
|
}
|
|
|
|
// Second try: existing sessions
|
|
for (const [senderFP, sessData] of Object.entries(sessions)) {
|
|
try {
|
|
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, sessData.data);
|
|
const result = JSON.parse(resultStr);
|
|
|
|
if (result.type === 'receipt') {
|
|
dbg('Received', result.receipt_type, 'receipt for', result.message_id);
|
|
updateReceiptDisplay(result.message_id, result.receipt_type);
|
|
return;
|
|
}
|
|
if (result.type === 'file_header') { handleFileHeader(result); return; }
|
|
if (result.type === 'file_chunk') { handleFileChunk(result); return; }
|
|
|
|
dbg('Decrypted with session', senderFP);
|
|
|
|
sessions[senderFP] = { data: result.session_data };
|
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
|
));
|
|
|
|
let fromLabel = result.sender.slice(0, 19);
|
|
try {
|
|
const rfp = normFP(result.sender);
|
|
const ar = await fetch(SERVER + '/v1/resolve/' + rfp);
|
|
const ad = await ar.json();
|
|
if (ad.eth_address) fromLabel = ad.eth_address.slice(0, 12) + '...';
|
|
const aw = await fetch(SERVER + '/v1/alias/whois/' + rfp);
|
|
const adata = await aw.json();
|
|
if (adata.alias) fromLabel = '@' + adata.alias;
|
|
} catch(e2) {}
|
|
|
|
addMsg(fromLabel, result.text, false);
|
|
if (result.message_id) sendReceipt(result.sender, result.message_id, 'delivered');
|
|
lastDmPeer = normFP(result.sender);
|
|
return;
|
|
} catch(e2) {
|
|
dbg('Session', senderFP, 'failed:', e2.message || e2);
|
|
}
|
|
}
|
|
|
|
// Last try: raw JSON file messages (from web file upload) or bot messages
|
|
try {
|
|
const str = new TextDecoder().decode(bytes);
|
|
const json = JSON.parse(str);
|
|
if (json.type === 'file_header') { handleFileHeader(json); return; }
|
|
if (json.type === 'file_chunk') { handleFileChunk(json); return; }
|
|
// Handle bot messages (plaintext JSON from bot API)
|
|
if (json.type === 'bot_message') {
|
|
const botName = json.from_name || json.from || 'bot';
|
|
let msgText = json.text || '';
|
|
// Handle inline keyboard if present
|
|
if (json.reply_markup && json.reply_markup.inline_keyboard) {
|
|
msgText += '\\n';
|
|
for (const row of json.reply_markup.inline_keyboard) {
|
|
for (const btn of row) {
|
|
msgText += ' [' + btn.text + '] ';
|
|
}
|
|
msgText += '\\n';
|
|
}
|
|
}
|
|
const useHtml = json.parse_mode === 'HTML';
|
|
addMsg('@' + botName, msgText, false, null, useHtml);
|
|
lastDmPeer = json.from ? normFP(json.from) : '';
|
|
return;
|
|
}
|
|
if (json.type === 'bot_edit') {
|
|
addSys('[bot updated message: ' + (json.text || '') + ']');
|
|
return;
|
|
}
|
|
if (json.type === 'bot_document') {
|
|
const caption = json.caption ? ' \u2014 ' + json.caption : '';
|
|
addMsg('@' + (json.from || 'bot'), '[Document: ' + json.document + caption + ']', false);
|
|
return;
|
|
}
|
|
} catch(e) {}
|
|
|
|
dbg('ALL decrypt attempts failed');
|
|
addSys('[message could not be decrypted]');
|
|
}
|
|
|
|
// Load saved sessions
|
|
try {
|
|
const saved = localStorage.getItem('wz-sessions');
|
|
if (saved) {
|
|
const parsed = JSON.parse(saved);
|
|
for (const [k, v] of Object.entries(parsed)) {
|
|
sessions[k] = { data: v };
|
|
}
|
|
}
|
|
} catch(e) {}
|
|
|
|
// ── UI ──
|
|
|
|
function ts() {
|
|
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function esc(s) {
|
|
const d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function renderMd(text) {
|
|
let s = esc(text);
|
|
// Code blocks: ```...```
|
|
s = s.replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
|
|
// Inline code: `...`
|
|
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
// Bold: **...**
|
|
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
// Italic: *...*
|
|
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
|
|
// Headers: ### ... (at line start)
|
|
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
s = s.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
|
// Links: [text](url)
|
|
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
// Blockquotes: > ...
|
|
s = s.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
|
|
// Unordered lists: - ...
|
|
s = s.replace(/^- (.+)$/gm, '<li>$1</li>');
|
|
s = s.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
|
|
// Line breaks
|
|
s = s.replace(/\n/g, '<br>');
|
|
// Clean up br inside pre
|
|
s = s.replace(/<pre><code>([\s\S]*?)<\/code><\/pre>/g, (m, code) => '<pre><code>' + code.replace(/<br>/g, '\n') + '</code></pre>');
|
|
return s;
|
|
}
|
|
|
|
const PEER_COLORS = ['#e6a23c','#f56c9d','#67c7eb','#b39ddb','#ff8a65','#81c784','#ce93d8','#4fc3f7','#ffb74d','#aed581','#f06292','#4dd0e1'];
|
|
|
|
function peerColor(name) {
|
|
let h = 0;
|
|
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;
|
|
return PEER_COLORS[Math.abs(h) % PEER_COLORS.length];
|
|
}
|
|
|
|
// ── File transfer handling ──
|
|
|
|
function handleFileHeader(result) {
|
|
const id = result.id;
|
|
pendingFiles[id] = {
|
|
filename: result.filename,
|
|
file_size: result.file_size,
|
|
total_chunks: result.total_chunks,
|
|
sha256: result.sha256,
|
|
sender: result.sender,
|
|
chunks: new Array(result.total_chunks).fill(null),
|
|
received: 0,
|
|
};
|
|
addSys('Incoming file "' + result.filename + '" from ' + result.sender.slice(0,12) + ' (' + formatSize(result.file_size) + ', ' + result.total_chunks + ' chunks)');
|
|
}
|
|
|
|
function handleFileChunk(result) {
|
|
const pf = pendingFiles[result.id];
|
|
if (!pf) {
|
|
dbg('Received chunk for unknown file', result.id);
|
|
return;
|
|
}
|
|
// Decode hex data
|
|
const data = new Uint8Array(result.data.match(/.{1,2}/g).map(b => parseInt(b, 16)));
|
|
pf.chunks[result.chunk_index] = data;
|
|
pf.received++;
|
|
|
|
addSys('Receiving "' + pf.filename + '" [' + pf.received + '/' + pf.total_chunks + ']');
|
|
|
|
if (pf.received === pf.total_chunks) {
|
|
// Assemble file
|
|
let totalLen = 0;
|
|
for (const c of pf.chunks) totalLen += c.length;
|
|
const assembled = new Uint8Array(totalLen);
|
|
let offset = 0;
|
|
for (const c of pf.chunks) {
|
|
assembled.set(c, offset);
|
|
offset += c.length;
|
|
}
|
|
|
|
// Show clickable download link
|
|
const blob = new Blob([assembled]);
|
|
const url = URL.createObjectURL(blob);
|
|
const d = document.createElement('div');
|
|
d.className = 'msg';
|
|
d.innerHTML = '<span class="ts">' + ts() + '</span> 📎 <a href="' + url + '" download="' + esc(pf.filename) + '" style="color:#67c7eb;background:#0f3460;padding:4px 10px;border-radius:4px;text-decoration:none">' + esc(pf.filename) + ' (' + formatSize(assembled.length) + ')</a> from ' + esc(pf.sender.slice(0,12));
|
|
$messages.appendChild(d);
|
|
$messages.scrollTop = $messages.scrollHeight;
|
|
delete pendingFiles[result.id];
|
|
}
|
|
}
|
|
|
|
function formatSize(n) {
|
|
if (n < 1024) return n + ' B';
|
|
if (n < 1048576) return (n/1024).toFixed(1) + ' KB';
|
|
return (n/1048576).toFixed(1) + ' MB';
|
|
}
|
|
|
|
function addMsg(from, text, isSelf, messageId, rawHtml) {
|
|
const d = document.createElement('div');
|
|
d.className = 'msg';
|
|
const color = isSelf ? '#4ade80' : peerColor(from);
|
|
const lock = isSelf ? '' : '<span class="lock">🔒 </span>';
|
|
let receiptHtml = '';
|
|
if (isSelf && messageId) {
|
|
const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent';
|
|
receiptHtml = ' <span class="receipt" style="color:' + receiptColor(status) + '"> ' + receiptIndicator(status) + '</span>';
|
|
}
|
|
const bodyHtml = rawHtml ? text : makeAddressClickable(renderMd(text));
|
|
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + makeAddressClickable(esc(from)) + '</span>: ' + bodyHtml + receiptHtml;
|
|
// Attach click handler for .addr spans
|
|
d.querySelectorAll('.addr').forEach(el => {
|
|
el.addEventListener('click', () => handleAddrClick(el.dataset.addr));
|
|
});
|
|
$messages.appendChild(d);
|
|
$messages.scrollTop = $messages.scrollHeight;
|
|
// Store reference to the receipt span so we can update it later
|
|
if (isSelf && messageId) {
|
|
const receiptEl = d.querySelector('.receipt');
|
|
if (!sentMsgReceipts[messageId]) sentMsgReceipts[messageId] = { status: 'sent', el: null };
|
|
sentMsgReceipts[messageId].el = receiptEl;
|
|
}
|
|
}
|
|
|
|
function addSys(text) {
|
|
const d = document.createElement('div');
|
|
d.className = 'msg';
|
|
d.innerHTML = '<span class="ts">' + ts() + '</span> <span class="from-sys">' + esc(text) + '</span>';
|
|
$messages.appendChild(d);
|
|
$messages.scrollTop = $messages.scrollHeight;
|
|
}
|
|
|
|
// ── Actions ──
|
|
|
|
async function doGenerate() {
|
|
generateNewIdentity();
|
|
document.getElementById('my-fp').textContent = myFingerprint;
|
|
document.getElementById('my-seed').textContent = mySeedHex;
|
|
document.getElementById('gen-section').style.display = 'none';
|
|
document.getElementById('id-section').style.display = 'block';
|
|
}
|
|
|
|
async function doRecover() {
|
|
const input = document.getElementById('recover-input').value.trim().replace(/\s+/g, '');
|
|
try {
|
|
initIdentityFromSeed(input);
|
|
document.getElementById('my-fp').textContent = myFingerprint;
|
|
document.getElementById('my-seed').textContent = '(recovered)';
|
|
document.getElementById('gen-section').style.display = 'none';
|
|
document.getElementById('id-section').style.display = 'block';
|
|
} catch(e) {
|
|
alert('Invalid seed: ' + e.message);
|
|
}
|
|
}
|
|
|
|
let currentGroup = null; // if set, messages go to group
|
|
let lastDmPeer = null; // for /r reply
|
|
let pendingFiles = {}; // file_id -> { filename, chunks: [], total, received, sha256, sender }
|
|
|
|
async function enterChat() {
|
|
document.getElementById('setup').classList.remove('active');
|
|
document.getElementById('chat').classList.add('active');
|
|
document.getElementById('hdr-server').textContent = SERVER;
|
|
|
|
await registerKey();
|
|
// Show ETH in header, fallback to fingerprint
|
|
const hdrFp = document.getElementById('hdr-fp');
|
|
if (myEthAddress) {
|
|
hdrFp.textContent = myEthAddress.slice(0, 12) + '...';
|
|
hdrFp.title = myEthAddress;
|
|
hdrFp.onclick = function() { navigator.clipboard.writeText(myEthAddress); addSys('Copied: ' + myEthAddress); };
|
|
} else {
|
|
hdrFp.textContent = (myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19));
|
|
hdrFp.title = myFingerprint;
|
|
}
|
|
addSys('Identity: ' + (myEthAddress || myFingerprint));
|
|
addSys('Key registered with server');
|
|
|
|
addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above');
|
|
addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /friend · /file · /info');
|
|
|
|
// Show system bots if available
|
|
try {
|
|
const botResp = await fetch(SERVER + '/v1/bot/list');
|
|
const botData = await botResp.json();
|
|
if (botData.ok && botData.bots && botData.bots.length > 0) {
|
|
addSys('');
|
|
addSys('Available bots:');
|
|
for (const b of botData.bots) {
|
|
addSys(' @' + b.name + ' — ' + b.description);
|
|
}
|
|
addSys('Message a bot: /peer @botname');
|
|
}
|
|
} catch(e) {}
|
|
|
|
const savedPeer = localStorage.getItem('wz-peer');
|
|
if (savedPeer) {
|
|
$peerInput.value = savedPeer;
|
|
}
|
|
|
|
connectWebSocket();
|
|
|
|
// Auto-join #ops if no peer/group set (create if needed)
|
|
if (!savedPeer) {
|
|
setTimeout(async () => {
|
|
try {
|
|
// Create #ops if it doesn't exist (ignore error if already exists)
|
|
await fetch(SERVER + '/v1/groups/create', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({name:'ops', creator: normFP(myFingerprint)}) });
|
|
// Join (no auth needed for join in current setup)
|
|
await fetch(SERVER + '/v1/groups/ops/join', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({fingerprint: normFP(myFingerprint)}) });
|
|
currentGroup = 'ops';
|
|
$peerInput.value = '#ops';
|
|
addSys('Welcome! You have been added to #ops');
|
|
} catch(e) { dbg('Auto-join #ops failed:', e); }
|
|
}, 500);
|
|
}
|
|
|
|
$input.focus();
|
|
}
|
|
|
|
// ── Group helpers ──
|
|
|
|
async function groupCreate(name) {
|
|
const resp = await fetch(SERVER + '/v1/groups/create', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, creator: normFP(myFingerprint) })
|
|
});
|
|
const data = await resp.json();
|
|
if (data.error) { addSys('Error: ' + data.error); return; }
|
|
addSys('Group "' + name + '" created. Join with /gjoin ' + name);
|
|
}
|
|
|
|
async function groupJoin(name) {
|
|
const resp = await fetch(SERVER + '/v1/groups/' + name + '/join', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fingerprint: normFP(myFingerprint) })
|
|
});
|
|
const data = await resp.json();
|
|
if (data.error) { addSys('Error: ' + data.error); return; }
|
|
addSys('Joined group "' + name + '" (' + data.members + ' members)');
|
|
}
|
|
|
|
async function showGroupMembers(groupName) {
|
|
try {
|
|
const resp = await fetch(SERVER + '/v1/groups/' + groupName + '/members');
|
|
const data = await resp.json();
|
|
if (data.members && data.members.length > 0) {
|
|
const online = data.members.filter(m => m.online).length;
|
|
addSys('Members of #' + groupName + ' (' + online + '/' + data.members.length + ' online):');
|
|
for (const m of data.members) {
|
|
const status = m.online ? '\u{1F7E2}' : '\u26AA';
|
|
const label = m.alias ? '@' + m.alias : m.fingerprint.slice(0, 16) + '...';
|
|
addSys(' ' + status + ' ' + label + (m.is_creator ? ' *' : ''));
|
|
}
|
|
}
|
|
} catch(e) { dbg('Failed to fetch members:', e); }
|
|
}
|
|
|
|
async function groupSwitch(name) {
|
|
// Auto-join
|
|
await groupJoin(name);
|
|
const resp = await fetch(SERVER + '/v1/groups/' + name);
|
|
const data = await resp.json();
|
|
if (data.error) { addSys('Error: ' + data.error); return; }
|
|
currentGroup = name;
|
|
$peerInput.value = '#' + name;
|
|
addSys('Switched to group "' + name + '" (' + data.count + ' members: ' + data.members.map(m => m.slice(0,8)).join(', ') + ')');
|
|
await showGroupMembers(name);
|
|
}
|
|
|
|
async function groupList() {
|
|
const resp = await fetch(SERVER + '/v1/groups');
|
|
const data = await resp.json();
|
|
if (!data.groups || data.groups.length === 0) { addSys('No groups'); return; }
|
|
for (const g of data.groups) {
|
|
addSys(' #' + g.name + ' (' + g.members + ' members)');
|
|
}
|
|
}
|
|
|
|
async function sendToGroup(groupName, text) {
|
|
// Get member list
|
|
const resp = await fetch(SERVER + '/v1/groups/' + groupName);
|
|
const data = await resp.json();
|
|
if (data.error) { addSys('Error: ' + data.error); return; }
|
|
|
|
const myFP = normFP(myFingerprint);
|
|
const members = data.members.filter(m => m !== myFP);
|
|
if (members.length === 0) { addSys('No other members in group'); return; }
|
|
|
|
// Encrypt for each member using WASM (same crypto as CLI)
|
|
const messages = [];
|
|
for (const memberFP of members) {
|
|
try {
|
|
const bundleBytes = await fetchPeerBundle(memberFP);
|
|
let wireBytes;
|
|
if (sessions[memberFP]) {
|
|
const sess = WasmSession.restore(sessions[memberFP].data);
|
|
wireBytes = sess.encrypt(wasmIdentity, text);
|
|
sessions[memberFP].data = sess.save();
|
|
} else {
|
|
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
|
|
wireBytes = sess.encrypt_key_exchange(wasmIdentity, bundleBytes, text);
|
|
sessions[memberFP] = { data: sess.save() };
|
|
}
|
|
messages.push({ to: memberFP, message: Array.from(wireBytes) });
|
|
} catch(e) {
|
|
// Skip members we can't encrypt for
|
|
}
|
|
}
|
|
// Save sessions
|
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
|
));
|
|
|
|
if (messages.length === 0) { addSys('No members to send to'); return; }
|
|
|
|
await fetch(SERVER + '/v1/groups/' + groupName + '/send', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ from: myFP, messages })
|
|
});
|
|
|
|
addMsg((myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)) + ' [' + groupName + ']', text, true, null);
|
|
}
|
|
|
|
// ── Send handler ──
|
|
|
|
async function doSend() {
|
|
const text = $input.value.trim();
|
|
$input.value = '';
|
|
$input.style.height = 'auto';
|
|
if (!text) return;
|
|
|
|
// Commands
|
|
if (text === '/info') {
|
|
const aliasResp = await fetch(SERVER + '/v1/alias/whois/' + normFP(myFingerprint));
|
|
const aliasData = await aliasResp.json();
|
|
const aliasStr = aliasData.alias ? ' (@' + aliasData.alias + ')' : '';
|
|
addSys('Fingerprint: ' + myFingerprint + aliasStr);
|
|
if (myEthAddress) addSys('ETH Address: ' + myEthAddress);
|
|
return;
|
|
}
|
|
if (text === '/clear') { $messages.innerHTML = ''; return; }
|
|
if (text === '/install') {
|
|
if (deferredInstall) { deferredInstall.prompt(); deferredInstall = null; }
|
|
else addSys('Install not available (already installed or not supported)');
|
|
return;
|
|
}
|
|
if (text === '/debug') { DEBUG = !DEBUG; addSys('Debug logging: ' + (DEBUG ? 'ON (check browser console)' : 'OFF')); return; }
|
|
if (text === '/reset') {
|
|
localStorage.clear();
|
|
addSys('localStorage cleared. Refresh the page to start fresh.');
|
|
return;
|
|
}
|
|
if (text === '/sessions') {
|
|
addSys('Sessions: ' + Object.keys(sessions).join(', '));
|
|
addSys('SPK secret: ' + (mySpkSecretHex ? mySpkSecretHex.slice(0,16) + '...' : 'NONE'));
|
|
return;
|
|
}
|
|
if (text === '/selftest') {
|
|
try {
|
|
const r = self_test();
|
|
addSys(r);
|
|
} catch(e) { addSys('Self-test FAILED: ' + e); }
|
|
return;
|
|
}
|
|
if (text === '/bundleinfo') {
|
|
try {
|
|
const r = debug_bundle_info(wasmIdentity);
|
|
addSys(r);
|
|
} catch(e) { addSys('Bundle info error: ' + e); }
|
|
return;
|
|
}
|
|
if (text === '/seed') {
|
|
addSys('Your recovery seed (keep secret!):');
|
|
addSys(wasmIdentity.mnemonic());
|
|
return;
|
|
}
|
|
if (text === '/quit') { window.close(); return; }
|
|
if (text === '/glist') { await groupList(); return; }
|
|
if (text === '/dm') { currentGroup = null; addSys('Switched to DM mode'); $peerInput.value = localStorage.getItem('wz-peer') || ''; return; }
|
|
if (text === '/aliases') {
|
|
const resp = await fetch(SERVER + '/v1/alias/list');
|
|
const data = await resp.json();
|
|
if (data.aliases.length === 0) { addSys('No aliases registered'); }
|
|
else { for (const a of data.aliases) addSys(' @' + a.alias + ' → ' + a.fingerprint.slice(0,16) + '...'); }
|
|
return;
|
|
}
|
|
|
|
if (text.startsWith('/alias ')) {
|
|
const name = text.slice(7).trim();
|
|
const resp = await fetch(SERVER + '/v1/alias/register', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ alias: name, fingerprint: normFP(myFingerprint) })
|
|
});
|
|
const data = await resp.json();
|
|
if (data.error) { addSys('Error: ' + data.error); }
|
|
else { addSys('Alias @' + data.alias + ' registered. Recovery key: ' + (data.recovery_key || 'N/A')); }
|
|
return;
|
|
}
|
|
if (text === '/unalias') {
|
|
const resp = await fetch(SERVER + '/v1/alias/unregister', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fingerprint: normFP(myFingerprint) })
|
|
});
|
|
const data = await resp.json();
|
|
if (data.error) addSys('Error: ' + data.error);
|
|
else addSys('Alias removed');
|
|
return;
|
|
}
|
|
if (text.startsWith('/admin-unalias ')) {
|
|
const parts = text.slice(15).trim().split(' ');
|
|
if (parts.length < 2) { addSys('Usage: /admin-unalias <alias> <admin-password>'); return; }
|
|
const resp = await fetch(SERVER + '/v1/alias/admin-remove', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ alias: parts[0], admin_password: parts.slice(1).join(' ') })
|
|
});
|
|
const data = await resp.json();
|
|
if (data.error) addSys('Error: ' + data.error);
|
|
else addSys('Alias @' + parts[0] + ' removed by admin');
|
|
return;
|
|
}
|
|
if (text.startsWith('/r ') || text.startsWith('/reply ')) {
|
|
const replyText = text.startsWith('/r ') ? text.slice(3) : text.slice(7);
|
|
if (!lastDmPeer) { addSys('No one to reply to'); return; }
|
|
$peerInput.value = lastDmPeer;
|
|
try {
|
|
await sendEncrypted(lastDmPeer, replyText.trim());
|
|
addMsg((myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)), replyText.trim(), true);
|
|
} catch(e) { addSys('Reply failed: ' + e.message); }
|
|
return;
|
|
}
|
|
if (text.startsWith('/p ') || text.startsWith('/peer ')) {
|
|
let val = text.startsWith('/p ') ? text.slice(3).trim() : text.slice(6).trim();
|
|
if (val.startsWith('@') || val.startsWith('0x') || val.startsWith('0X')) {
|
|
const endpoint = val.startsWith('@') ? '/v1/alias/resolve/' + val.slice(1) : '/v1/resolve/' + val;
|
|
const resp = await fetch(SERVER + endpoint);
|
|
const data = await resp.json();
|
|
if (data.error) { addSys('Cannot resolve ' + val + ': ' + data.error); return; }
|
|
$peerInput.value = data.fingerprint;
|
|
addSys(val + ' \u2192 ' + data.fingerprint.slice(0,16) + '...');
|
|
} else {
|
|
$peerInput.value = val;
|
|
}
|
|
currentGroup = null;
|
|
localStorage.setItem('wz-peer', $peerInput.value);
|
|
addSys('Peer set to ' + $peerInput.value.slice(0,16) + '...');
|
|
return;
|
|
}
|
|
if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; }
|
|
if (text.startsWith('/gjoin ')) { await groupJoin(text.slice(7).trim()); return; }
|
|
if (text === '/gleave') {
|
|
if (!currentGroup) { addSys('Not in a group'); return; }
|
|
const r = await fetch(SERVER+'/v1/groups/'+currentGroup+'/leave',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({fingerprint:normFP(myFingerprint)})});
|
|
const d = await r.json();
|
|
if (d.error) addSys('Error: '+d.error); else { addSys('Left group "'+currentGroup+'"'); currentGroup=null; $peerInput.value=''; }
|
|
return;
|
|
}
|
|
if (text.startsWith('/gkick ')) {
|
|
if (!currentGroup) { addSys('Not in a group'); return; }
|
|
const target = text.slice(7).trim();
|
|
const r = await fetch(SERVER+'/v1/groups/'+currentGroup+'/kick',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({fingerprint:normFP(myFingerprint),target:target})});
|
|
const d = await r.json();
|
|
if (d.error) addSys('Error: '+d.error); else addSys('Kicked '+d.kicked);
|
|
return;
|
|
}
|
|
if (text === '/gmembers') {
|
|
if (!currentGroup) { addSys('Not in a group'); return; }
|
|
const r = await fetch(SERVER+'/v1/groups/'+currentGroup+'/members');
|
|
const d = await r.json();
|
|
if (d.error) { addSys('Error: '+d.error); return; }
|
|
addSys('Members of #'+currentGroup+':');
|
|
for (const m of d.members) {
|
|
const a = m.alias ? '@'+m.alias : m.fingerprint.slice(0,12)+'...';
|
|
addSys(' '+a+(m.is_creator?' ★':''));
|
|
}
|
|
return;
|
|
}
|
|
if (text === '/friend' || text === '/friends') {
|
|
try {
|
|
const resp = await fetch(SERVER + '/v1/friends', {
|
|
headers: { 'Authorization': 'Bearer ' + normFP(myFingerprint) }
|
|
});
|
|
const data = await resp.json();
|
|
if (data.data) {
|
|
addSys('Friends:');
|
|
addSys('(encrypted friend list stored on server -- use TUI for full friend management)');
|
|
} else {
|
|
addSys('No friends yet. Use /friend <address> to add.');
|
|
}
|
|
} catch(e) { addSys('Error: ' + e.message); }
|
|
return;
|
|
}
|
|
if (text.startsWith('/friend ')) {
|
|
const addr = text.slice(8).trim();
|
|
if (!addr) { addSys('Usage: /friend <address>'); return; }
|
|
addSys('Friend management requires TUI client (encrypted locally). Use warzone-client for full support.');
|
|
addSys('Hint: /friend in TUI to manage friends with E2E encryption.');
|
|
return;
|
|
}
|
|
if (text.startsWith('/unfriend ')) {
|
|
addSys('Friend management requires TUI client (encrypted locally).');
|
|
return;
|
|
}
|
|
if (text.startsWith('/g ')) { await groupSwitch(text.slice(3).trim()); return; }
|
|
|
|
// Send to group or DM
|
|
if (currentGroup) {
|
|
try {
|
|
await sendToGroup(currentGroup, text);
|
|
} catch(e) {
|
|
addSys('Group send failed: ' + e.message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// DM — resolve @alias if needed
|
|
let peer = $peerInput.value.trim();
|
|
if (!peer || peer.startsWith('#')) { addSys('Set a peer fingerprint/@alias or use /g <group>'); return; }
|
|
|
|
if (peer.startsWith('@') || peer.startsWith('0x') || peer.startsWith('0X')) {
|
|
const endpoint = peer.startsWith('@') ? '/v1/alias/resolve/' + peer.slice(1) : '/v1/resolve/' + peer;
|
|
const resp = await fetch(SERVER + endpoint);
|
|
const data = await resp.json();
|
|
if (data.error) { addSys('Cannot resolve ' + peer + ': ' + data.error); return; }
|
|
peer = data.fingerprint;
|
|
}
|
|
|
|
localStorage.setItem('wz-peer', $peerInput.value.trim());
|
|
|
|
// Check if peer is a bot — send plaintext instead of E2E
|
|
let isBotPeer = false;
|
|
const peerRaw = $peerInput.value.trim();
|
|
// Check by alias name if peer was set via @alias
|
|
if (peerRaw.startsWith('@')) {
|
|
const aname = peerRaw.slice(1).toLowerCase();
|
|
isBotPeer = aname.endsWith('bot') || aname.endsWith('_bot') || aname === 'botfather';
|
|
}
|
|
// Also check by fingerprint reverse-lookup
|
|
if (!isBotPeer) {
|
|
try {
|
|
const wr = await fetch(SERVER + '/v1/alias/whois/' + peer);
|
|
const wd = await wr.json();
|
|
if (wd.alias && (wd.alias.endsWith('bot') || wd.alias.endsWith('Bot') || wd.alias.endsWith('_bot') || wd.alias === 'botfather')) isBotPeer = true;
|
|
} catch(e) {}
|
|
}
|
|
|
|
if (isBotPeer) {
|
|
const msgId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString();
|
|
const botMsg = {type:'bot_message',id:msgId,from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:text,timestamp:Math.floor(Date.now()/1000)};
|
|
await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:normFP(peer),from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(botMsg)))})});
|
|
addMsg((myEthAddress ? myEthAddress.slice(0,12)+'...' : myFingerprint.slice(0,19)), text, true, msgId);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const msgId = await sendEncrypted(peer, text);
|
|
sentMsgReceipts[msgId] = { status: 'sent', el: null };
|
|
addMsg((myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)), text, true, msgId);
|
|
} catch(e) {
|
|
addSys('Send failed: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Keyboard
|
|
$input.onkeydown = function(e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
|
|
};
|
|
$input.addEventListener('input', function() {
|
|
this.style.height = 'auto';
|
|
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
|
});
|
|
|
|
// Wire up buttons (module scope can't use onclick in HTML)
|
|
document.getElementById('btn-generate').onclick = () => doGenerate();
|
|
document.getElementById('btn-show-recover').onclick = () => document.getElementById('recover-area').style.display = 'block';
|
|
document.getElementById('btn-recover').onclick = () => doRecover();
|
|
document.getElementById('btn-enter').onclick = () => enterChat();
|
|
document.getElementById('send-btn').onclick = () => doSend();
|
|
document.getElementById('messages').onclick = () => { if (!window.getSelection().toString()) document.getElementById('msg-input').focus(); };
|
|
document.getElementById('hdr-eth').onclick = function() {
|
|
if (myEthAddress) navigator.clipboard.writeText(myEthAddress).then(() => addSys('Copied ETH address'));
|
|
};
|
|
document.getElementById('file-input').onchange = async function() {
|
|
if (!this.files.length) return;
|
|
const file = this.files[0];
|
|
if (file.size > 10 * 1024 * 1024) { addSys('File too large (max 10MB)'); this.value=''; return; }
|
|
|
|
const peer = $peerInput.value.trim();
|
|
if (!peer || peer.startsWith('#')) { addSys('Set a peer first'); this.value=''; return; }
|
|
const fp = normFP(peer);
|
|
|
|
const data = new Uint8Array(await file.arrayBuffer());
|
|
const CHUNK_SIZE = 65536;
|
|
const totalChunks = Math.ceil(data.length / CHUNK_SIZE);
|
|
const fileId = crypto.randomUUID();
|
|
|
|
// SHA-256 hash
|
|
const hashBuf = await crypto.subtle.digest('SHA-256', data);
|
|
const sha256 = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2,'0')).join('');
|
|
|
|
addSys('Sending "' + file.name + '" (' + formatSize(data.length) + ', ' + totalChunks + ' chunks)...');
|
|
|
|
// Send header via HTTP (file messages are large, WS might choke)
|
|
const headerWire = { type: 'file_header', id: fileId, sender: normFP(myFingerprint), filename: file.name, file_size: data.length, total_chunks: totalChunks, sha256: sha256 };
|
|
// For now send as raw JSON message (server treats as opaque bytes)
|
|
const headerBytes = new TextEncoder().encode(JSON.stringify(headerWire));
|
|
await fetch(SERVER + '/v1/messages/send', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ to: fp, from: normFP(myFingerprint), message: Array.from(headerBytes) })
|
|
});
|
|
|
|
for (let i = 0; i < totalChunks; i++) {
|
|
const chunk = data.slice(i * CHUNK_SIZE, (i+1) * CHUNK_SIZE);
|
|
const chunkWire = { type: 'file_chunk', id: fileId, sender: normFP(myFingerprint), filename: file.name, chunk_index: i, total_chunks: totalChunks, data: Array.from(chunk).map(b => b.toString(16).padStart(2,'0')).join('') };
|
|
const chunkBytes = new TextEncoder().encode(JSON.stringify(chunkWire));
|
|
await fetch(SERVER + '/v1/messages/send', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ to: fp, from: normFP(myFingerprint), message: Array.from(chunkBytes) })
|
|
});
|
|
addSys('Sent chunk ' + (i+1) + '/' + totalChunks);
|
|
}
|
|
|
|
addSys('File "' + file.name + '" sent');
|
|
this.value = '';
|
|
};
|
|
|
|
// PWA: service worker + install prompt
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('/sw.js').catch(e => dbg('SW failed:', e));
|
|
}
|
|
let deferredInstall = null;
|
|
window.addEventListener('beforeinstallprompt', e => {
|
|
e.preventDefault();
|
|
deferredInstall = e;
|
|
addSys('Tip: install as app for fullscreen + notifications. Type /install');
|
|
});
|
|
|
|
// Initialize WASM and auto-load
|
|
(async function() {
|
|
try {
|
|
await initWasm();
|
|
if (loadSavedIdentity()) {
|
|
enterChat();
|
|
}
|
|
} catch(e) {
|
|
addSys('Failed to load WASM crypto: ' + e.message);
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>"##;
|