diff --git a/warzone/crates/warzone-client/src/net.rs b/warzone/crates/warzone-client/src/net.rs index a6e151b..42fa0f8 100644 --- a/warzone/crates/warzone-client/src/net.rs +++ b/warzone/crates/warzone-client/src/net.rs @@ -7,7 +7,7 @@ use warzone_protocol::prekey::PreKeyBundle; #[derive(Clone)] pub struct ServerClient { pub base_url: String, - client: reqwest::Client, + pub client: reqwest::Client, } #[derive(Serialize)] diff --git a/warzone/crates/warzone-client/src/tui/app.rs b/warzone/crates/warzone-client/src/tui/app.rs index bad7b71..50d1cc8 100644 --- a/warzone/crates/warzone-client/src/tui/app.rs +++ b/warzone/crates/warzone-client/src/tui/app.rs @@ -192,9 +192,50 @@ impl App { self.peer_fp = Some(fp); return; } + if text.starts_with("/gcreate ") { + let name = text[9..].trim(); + self.group_create(name, client).await; + return; + } + if text.starts_with("/gjoin ") { + let name = text[7..].trim(); + self.group_join(name, client).await; + return; + } + if text.starts_with("/g ") { + let name = text[3..].trim().to_string(); + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Switched to group #{}", name), + is_system: true, + is_self: false, + }); + self.peer_fp = Some(format!("#{}", name)); + return; + } + if text == "/dm" { + self.add_message(ChatLine { + sender: "system".into(), + text: "Switched to DM mode. Use /peer ".into(), + is_system: true, + is_self: false, + }); + self.peer_fp = None; + return; + } + if text == "/glist" { + self.group_list(client).await; + return; + } - // Send message + // Send message (group or DM) let peer = match &self.peer_fp { + Some(p) if p.starts_with('#') => { + // Group mode + let group_name = p[1..].to_string(); + self.group_send(&group_name, &text, identity, db, client).await; + return; + } Some(p) => p.clone(), None => { self.add_message(ChatLine { @@ -328,6 +369,183 @@ impl App { } } } + + async fn group_create(&self, name: &str, client: &ServerClient) { + let url = format!("{}/v1/groups/create", client.base_url); + match client.client.post(&url) + .json(&serde_json::json!({"name": name, "creator": normfp(&self.our_fp)})) + .send().await + { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if let Some(err) = data.get("error") { + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false }); + } else { + self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false }); + } + } + } + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }), + } + } + + async fn group_join(&self, name: &str, client: &ServerClient) { + let url = format!("{}/v1/groups/{}/join", client.base_url, name); + match client.client.post(&url) + .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)})) + .send().await + { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if let Some(err) = data.get("error") { + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false }); + } else { + let members = data.get("members").and_then(|v| v.as_u64()).unwrap_or(0); + self.add_message(ChatLine { sender: "system".into(), text: format!("Joined '{}' ({} members)", name, members), is_system: true, is_self: false }); + } + } + } + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }), + } + } + + async fn group_list(&self, client: &ServerClient) { + let url = format!("{}/v1/groups", client.base_url); + match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if let Some(groups) = data.get("groups").and_then(|v| v.as_array()) { + if groups.is_empty() { + self.add_message(ChatLine { sender: "system".into(), text: "No groups".into(), is_system: true, is_self: false }); + } else { + for g in groups { + let name = g.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let members = g.get("members").and_then(|v| v.as_u64()).unwrap_or(0); + self.add_message(ChatLine { sender: "system".into(), text: format!(" #{} ({} members)", name, members), is_system: true, is_self: false }); + } + } + } + } + } + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }), + } + } + + async fn group_send( + &self, + group_name: &str, + text: &str, + identity: &IdentityKeyPair, + db: &LocalDb, + client: &ServerClient, + ) { + // Get members + let url = format!("{}/v1/groups/{}", client.base_url, group_name); + let group_data = match client.client.get(&url).send().await { + Ok(resp) => match resp.json::().await { + Ok(d) => d, + Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }); return; } + }, + Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }); return; } + }; + + let my_fp = normfp(&self.our_fp); + let members: Vec = group_data.get("members") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + + let our_pub = identity.public_identity(); + let mut wire_messages: Vec = Vec::new(); + + for member in &members { + if *member == my_fp { continue; } + let member_fp = match Fingerprint::from_hex(member) { + Ok(fp) => fp, + Err(_) => continue, + }; + + let mut ratchet = db.load_session(&member_fp).ok().flatten(); + + let wire_msg = if let Some(ref mut state) = ratchet { + match state.encrypt(text.as_bytes()) { + Ok(encrypted) => { + let _ = db.save_session(&member_fp, state); + WireMessage::Message { + sender_fingerprint: our_pub.fingerprint.to_string(), + ratchet_message: encrypted, + } + } + Err(_) => continue, + } + } else { + // Need X3DH — fetch bundle + let bundle = match client.fetch_bundle(member).await { + Ok(b) => b, + Err(_) => continue, + }; + let x3dh_result = match x3dh::initiate(identity, &bundle) { + Ok(r) => r, + Err(_) => continue, + }; + let their_spk = PublicKey::from(bundle.signed_pre_key.public_key); + let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk); + match state.encrypt(text.as_bytes()) { + Ok(encrypted) => { + let _ = db.save_session(&member_fp, &state); + WireMessage::KeyExchange { + sender_fingerprint: our_pub.fingerprint.to_string(), + sender_identity_encryption_key: *our_pub.encryption.as_bytes(), + ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(), + used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id, + ratchet_message: encrypted, + } + } + Err(_) => continue, + } + }; + + let encoded = match bincode::serialize(&wire_msg) { + Ok(e) => e, + Err(_) => continue, + }; + + wire_messages.push(serde_json::json!({ + "to": member, + "message": encoded, + })); + } + + if wire_messages.is_empty() { + self.add_message(ChatLine { sender: "system".into(), text: "No members to send to".into(), is_system: true, is_self: false }); + return; + } + + let send_url = format!("{}/v1/groups/{}/send", client.base_url, group_name); + match client.client.post(&send_url) + .json(&serde_json::json!({ + "from": my_fp, + "messages": wire_messages, + })) + .send().await + { + Ok(_) => { + self.add_message(ChatLine { + sender: format!("{} [#{}]", &self.our_fp[..12], group_name), + text: text.to_string(), + is_system: false, + is_self: true, + }); + } + Err(e) => { + self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false }); + } + } + } +} + +fn normfp(fp: &str) -> String { + fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase() } /// Poll for incoming messages in the background. diff --git a/warzone/crates/warzone-server/src/db.rs b/warzone/crates/warzone-server/src/db.rs index afbc796..0ce908f 100644 --- a/warzone/crates/warzone-server/src/db.rs +++ b/warzone/crates/warzone-server/src/db.rs @@ -3,6 +3,7 @@ use anyhow::Result; pub struct Database { pub keys: sled::Tree, pub messages: sled::Tree, + pub groups: sled::Tree, _db: sled::Db, } @@ -11,9 +12,11 @@ impl Database { let db = sled::open(data_dir)?; let keys = db.open_tree("keys")?; let messages = db.open_tree("messages")?; + let groups = db.open_tree("groups")?; Ok(Database { keys, messages, + groups, _db: db, }) } diff --git a/warzone/crates/warzone-server/src/routes/groups.rs b/warzone/crates/warzone-server/src/routes/groups.rs new file mode 100644 index 0000000..1288ebc --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/groups.rs @@ -0,0 +1,193 @@ +use axum::{ + extract::{Path, State}, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; + +use crate::errors::AppResult; +use crate::state::AppState; + +pub fn routes() -> Router { + Router::new() + .route("/groups", get(list_groups)) + .route("/groups/create", post(create_group)) + .route("/groups/:name", get(get_group)) + .route("/groups/:name/join", post(join_group)) + .route("/groups/:name/send", post(send_to_group)) +} + +#[derive(Serialize, Deserialize, Clone)] +struct GroupInfo { + name: String, + creator: String, + members: Vec, // fingerprints +} + +#[derive(Deserialize)] +struct CreateRequest { + name: String, + creator: String, // fingerprint +} + +#[derive(Deserialize)] +struct JoinRequest { + fingerprint: String, +} + +/// A group message: the client sends one ciphertext per member. +/// Server fans out each entry to the respective member's message queue. +#[derive(Deserialize)] +struct GroupSendRequest { + from: String, + /// Each entry is an encrypted message destined for one member. + /// The client encrypts separately for each recipient. + messages: Vec, +} + +#[derive(Deserialize)] +struct MemberMessage { + to: String, // member fingerprint + message: Vec, // encrypted payload (same format as 1:1 messages) +} + +fn normalize_fp(fp: &str) -> String { + fp.chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase() +} + +fn load_group(db: &sled::Tree, name: &str) -> Option { + db.get(name.as_bytes()) + .ok() + .flatten() + .and_then(|data| serde_json::from_slice(&data).ok()) +} + +fn save_group(db: &sled::Tree, group: &GroupInfo) -> anyhow::Result<()> { + let data = serde_json::to_vec(group)?; + db.insert(group.name.as_bytes(), data)?; + Ok(()) +} + +async fn create_group( + State(state): State, + Json(req): Json, +) -> AppResult> { + let name = req.name.trim().to_lowercase(); + if name.is_empty() { + return Ok(Json(serde_json::json!({ "error": "name required" }))); + } + + if load_group(&state.db.groups, &name).is_some() { + return Ok(Json(serde_json::json!({ "error": "group already exists" }))); + } + + let creator = normalize_fp(&req.creator); + let group = GroupInfo { + name: name.clone(), + creator: creator.clone(), + members: vec![creator], + }; + save_group(&state.db.groups, &group)?; + tracing::info!("Group '{}' created", name); + Ok(Json(serde_json::json!({ "ok": true, "name": name }))) +} + +async fn join_group( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> AppResult> { + let fp = normalize_fp(&req.fingerprint); + + let mut group = match load_group(&state.db.groups, &name) { + Some(g) => g, + None => return Ok(Json(serde_json::json!({ "error": "group not found" }))), + }; + + if !group.members.contains(&fp) { + group.members.push(fp.clone()); + save_group(&state.db.groups, &group)?; + tracing::info!("{} joined group '{}'", fp, name); + } + + Ok(Json(serde_json::json!({ "ok": true, "members": group.members.len() }))) +} + +async fn get_group( + State(state): State, + Path(name): Path, +) -> AppResult> { + match load_group(&state.db.groups, &name) { + Some(group) => Ok(Json(serde_json::json!({ + "name": group.name, + "creator": group.creator, + "members": group.members, + "count": group.members.len(), + }))), + None => Ok(Json(serde_json::json!({ "error": "group not found" }))), + } +} + +async fn list_groups( + State(state): State, +) -> AppResult> { + let groups: Vec = state + .db + .groups + .iter() + .filter_map(|item| { + item.ok().and_then(|(_, data)| { + serde_json::from_slice::(&data).ok().map(|g| { + serde_json::json!({ + "name": g.name, + "members": g.members.len(), + }) + }) + }) + }) + .collect(); + Ok(Json(serde_json::json!({ "groups": groups }))) +} + +/// Fan-out: client sends per-member encrypted messages, server puts each +/// in the respective member's queue. This reuses the existing message +/// queue infrastructure — group messages look like 1:1 messages to the +/// recipient, but with a group tag. +async fn send_to_group( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> AppResult> { + let group = match load_group(&state.db.groups, &name) { + Some(g) => g, + None => return Ok(Json(serde_json::json!({ "error": "group not found" }))), + }; + + let from = normalize_fp(&req.from); + if !group.members.contains(&from) { + return Ok(Json(serde_json::json!({ "error": "not a member of this group" }))); + } + + let mut delivered = 0; + for msg in &req.messages { + let to = normalize_fp(&msg.to); + if group.members.contains(&to) { + let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4()); + state.db.messages.insert(key.as_bytes(), msg.message.as_slice())?; + delivered += 1; + } + } + + tracing::info!( + "Group '{}': {} sent {} messages to {} members", + name, + from, + delivered, + group.members.len() + ); + + Ok(Json(serde_json::json!({ "ok": true, "delivered": delivered }))) +} diff --git a/warzone/crates/warzone-server/src/routes/mod.rs b/warzone/crates/warzone-server/src/routes/mod.rs index 62c5497..3d43206 100644 --- a/warzone/crates/warzone-server/src/routes/mod.rs +++ b/warzone/crates/warzone-server/src/routes/mod.rs @@ -1,3 +1,4 @@ +mod groups; mod health; mod keys; mod messages; @@ -12,6 +13,7 @@ pub fn router() -> Router { .merge(health::routes()) .merge(keys::routes()) .merge(messages::routes()) + .merge(groups::routes()) } /// Web UI router (served at root, outside /v1) diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 43f6281..3c0ffbf 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -295,7 +295,9 @@ async function pollMessages() { const aesKey = await fetchPeerKey(env.from); const ct = fromHex(env.ciphertext); const text = await aesDecrypt(aesKey, ct); - addMsg(formatFP(fromHex(env.from)).slice(0, 19), text, false); + const fromLabel = formatFP(fromHex(env.from)).slice(0, 19); + const groupTag = env.group ? ' [' + env.group + ']' : ''; + addMsg(fromLabel + groupTag, text, false); continue; } } catch(e) { /* not JSON, might be CLI bincode */ } @@ -364,6 +366,8 @@ async function doRecover() { } } +let currentGroup = null; // if set, messages go to group + async function enterChat() { document.getElementById('setup').classList.remove('active'); document.getElementById('chat').classList.add('active'); @@ -373,18 +377,103 @@ async function enterChat() { await registerKey(); addSys('Identity loaded: ' + myFingerprint); addSys('Key registered with server'); - addSys('Paste a peer fingerprint above and start chatting'); - addSys('Commands: /info, /clear, /quit'); + addSys('DM: paste peer fingerprint above'); + addSys('Groups: /gcreate · /gjoin · /g · /glist'); + addSys('Other: /info · /clear · /dm (switch back to DM mode)'); - // Restore saved peer const savedPeer = localStorage.getItem('wz-peer'); if (savedPeer) $peerInput.value = savedPeer; - // Start polling pollTimer = setInterval(pollMessages, 2000); $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 groupSwitch(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(', ') + ')'); +} + +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 + const messages = []; + for (const memberFP of members) { + try { + const aesKey = await fetchPeerKey(memberFP); + const encrypted = await aesEncrypt(aesKey, text); + const envelope = JSON.stringify({ + type: 'web', + from: myFP, + group: groupName, + ciphertext: toHex(encrypted) + }); + messages.push({ + to: memberFP, + message: Array.from(new TextEncoder().encode(envelope)) + }); + } catch(e) { + addSys('Failed to encrypt for ' + memberFP.slice(0,8) + ': ' + e.message); + } + } + + if (messages.length === 0) return; + + await fetch(SERVER + '/v1/groups/' + groupName + '/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: myFP, messages }) + }); + + addMsg(myFingerprint.slice(0, 19) + ' [' + groupName + ']', text, true); +} + +// ── Send handler ── + async function doSend() { const text = $input.value.trim(); $input.value = ''; @@ -395,9 +484,26 @@ async function doSend() { if (text === '/info') { addSys('Fingerprint: ' + myFingerprint); return; } if (text === '/clear') { $messages.innerHTML = ''; 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.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; } + if (text.startsWith('/gjoin ')) { await groupJoin(text.slice(7).trim()); 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 const peer = $peerInput.value.trim(); - if (!peer) { addSys('Set a peer fingerprint first'); return; } + if (!peer || peer.startsWith('#')) { addSys('Set a peer fingerprint or use /g '); return; } localStorage.setItem('wz-peer', peer); try {