diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index d786776..87c37ce 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2647,7 +2647,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.8" +version = "0.0.9" dependencies = [ "anyhow", "argon2", @@ -2680,7 +2680,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.8" +version = "0.0.9" dependencies = [ "anyhow", "clap", @@ -2689,7 +2689,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.8" +version = "0.0.9" dependencies = [ "base64", "bincode", @@ -2712,7 +2712,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.8" +version = "0.0.9" dependencies = [ "anyhow", "axum", @@ -2739,7 +2739,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.8" +version = "0.0.9" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 58eaa99..767a58e 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.8" +version = "0.0.9" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/src/tui/app.rs b/warzone/crates/warzone-client/src/tui/app.rs index 6e18803..a3e9357 100644 --- a/warzone/crates/warzone-client/src/tui/app.rs +++ b/warzone/crates/warzone-client/src/tui/app.rs @@ -98,7 +98,7 @@ impl App { messages.lock().unwrap().push(ChatLine { sender: "system".into(), - text: "Commands: /alias , /peer , /g , /file , /info, /quit".into(), + text: "/alias /peer /g /gleave /gkick /gmembers /file /info /quit".into(), is_system: true, is_self: false, message_id: None, @@ -325,6 +325,41 @@ impl App { self.group_list(client).await; return; } + if text == "/gleave" { + if let Some(ref peer) = self.peer_fp { + if peer.starts_with('#') { + let name = peer[1..].to_string(); + self.group_leave(&name, client).await; + self.peer_fp = None; + } else { + self.add_message(ChatLine { sender: "system".into(), text: "Not in a group. Use /g first".into(), is_system: true, is_self: false, message_id: None }); + } + } + return; + } + if text.starts_with("/gkick ") { + if let Some(ref peer) = self.peer_fp { + if peer.starts_with('#') { + let name = peer[1..].to_string(); + let target = text[7..].trim().to_string(); + self.group_kick(&name, &target, client).await; + } else { + self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None }); + } + } + return; + } + if text == "/gmembers" { + if let Some(ref peer) = self.peer_fp { + if peer.starts_with('#') { + let name = peer[1..].to_string(); + self.group_members(&name, client).await; + } else { + self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None }); + } + } + return; + } if text.starts_with("/file ") { let path_str = text[6..].trim(); self.handle_file_send(path_str, identity, db, client).await; @@ -762,6 +797,69 @@ impl App { } } + async fn group_leave(&self, name: &str, client: &ServerClient) { + let url = format!("{}/v1/groups/{}/leave", 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, message_id: None }); + } else { + self.add_message(ChatLine { sender: "system".into(), text: format!("Left group '{}'", name), is_system: true, is_self: false, message_id: None }); + } + } + } + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), + } + } + + async fn group_kick(&self, name: &str, target: &str, client: &ServerClient) { + let url = format!("{}/v1/groups/{}/kick", client.base_url, name); + match client.client.post(&url) + .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp), "target": target})) + .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, message_id: None }); + } else { + let kicked = data.get("kicked").and_then(|v| v.as_str()).unwrap_or("?"); + self.add_message(ChatLine { sender: "system".into(), text: format!("Kicked {} from '{}'", kicked, name), is_system: true, is_self: false, message_id: None }); + } + } + } + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), + } + } + + async fn group_members(&self, name: &str, client: &ServerClient) { + let url = format!("{}/v1/groups/{}/members", client.base_url, name); + match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if let Some(members) = data.get("members").and_then(|v| v.as_array()) { + self.add_message(ChatLine { sender: "system".into(), text: format!("Members of #{}:", name), is_system: true, is_self: false, message_id: None }); + for m in members { + let fp = m.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?"); + let alias = m.get("alias").and_then(|v| v.as_str()); + let creator = m.get("is_creator").and_then(|v| v.as_bool()).unwrap_or(false); + let label = match alias { + Some(a) => format!(" @{} ({}{})", a, &fp[..fp.len().min(12)], if creator { " ★" } else { "" }), + None => format!(" {}...{}", &fp[..fp.len().min(12)], if creator { " ★" } else { "" }), + }; + self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None }); + } + } + } + } + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), + } + } + async fn group_send( &self, group_name: &str, diff --git a/warzone/crates/warzone-server/src/routes/groups.rs b/warzone/crates/warzone-server/src/routes/groups.rs index ca93f2c..73ceef5 100644 --- a/warzone/crates/warzone-server/src/routes/groups.rs +++ b/warzone/crates/warzone-server/src/routes/groups.rs @@ -15,6 +15,9 @@ pub fn routes() -> Router { .route("/groups/:name", get(get_group)) .route("/groups/:name/join", post(join_group)) .route("/groups/:name/send", post(send_to_group)) + .route("/groups/:name/leave", post(leave_group)) + .route("/groups/:name/kick", post(kick_member)) + .route("/groups/:name/members", get(get_members)) } #[derive(Serialize, Deserialize, Clone)] @@ -205,3 +208,90 @@ async fn send_to_group( Ok(Json(serde_json::json!({ "ok": true, "delivered": delivered }))) } + +async fn leave_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" }))), + }; + + group.members.retain(|m| m != &fp); + save_group(&state.db.groups, &group)?; + tracing::info!("{} left group '{}' ({} remaining)", fp, name, group.members.len()); + + Ok(Json(serde_json::json!({ "ok": true, "remaining": group.members.len() }))) +} + +#[derive(Deserialize)] +struct KickRequest { + fingerprint: String, // who is doing the kicking (must be creator) + target: String, // who to kick +} + +async fn kick_member( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> AppResult> { + let fp = normalize_fp(&req.fingerprint); + let target = normalize_fp(&req.target); + + 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.creator != fp { + return Ok(Json(serde_json::json!({ "error": "only the creator can kick members" }))); + } + + if target == fp { + return Ok(Json(serde_json::json!({ "error": "cannot kick yourself" }))); + } + + let before = group.members.len(); + group.members.retain(|m| m != &target); + if group.members.len() == before { + return Ok(Json(serde_json::json!({ "error": "target is not a member" }))); + } + + save_group(&state.db.groups, &group)?; + tracing::info!("{} kicked {} from group '{}'", fp, target, name); + + Ok(Json(serde_json::json!({ "ok": true, "kicked": target, "remaining": group.members.len() }))) +} + +async fn get_members( + State(state): State, + Path(name): Path, +) -> AppResult> { + let group = match load_group(&state.db.groups, &name) { + Some(g) => g, + None => return Ok(Json(serde_json::json!({ "error": "group not found" }))), + }; + + // Resolve aliases for each member + let mut members_info: Vec = Vec::new(); + for fp in &group.members { + let alias = state.db.aliases.get(format!("fp:{}", fp).as_bytes()) + .ok().flatten() + .map(|v| String::from_utf8_lossy(&v).to_string()); + members_info.push(serde_json::json!({ + "fingerprint": fp, + "alias": alias, + "is_creator": *fp == group.creator, + })); + } + + Ok(Json(serde_json::json!({ + "name": group.name, + "members": members_info, + "count": members_info.len(), + }))) +} diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index a67af7f..486d01d 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -160,7 +160,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.6'; +const VERSION = '0.0.9'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -568,7 +568,7 @@ async function enterChat() { addSys('Identity loaded: ' + myFingerprint); addSys('Key registered with server'); addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above'); - addSys('/alias · /g · /glist · /info · /selftest · /reset · /debug'); + addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /file · /info'); const savedPeer = localStorage.getItem('wz-peer'); if (savedPeer) $peerInput.value = savedPeer; @@ -733,6 +733,33 @@ async function doSend() { } 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.startsWith('/g ')) { await groupSwitch(text.slice(3).trim()); return; } // Send to group or DM