diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index a32ff6f..f8e1505 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2789,7 +2789,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.14" +version = "0.0.15" dependencies = [ "anyhow", "argon2", @@ -2822,7 +2822,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.14" +version = "0.0.15" dependencies = [ "anyhow", "clap", @@ -2831,7 +2831,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.14" +version = "0.0.15" dependencies = [ "base64", "bincode", @@ -2856,7 +2856,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.14" +version = "0.0.15" dependencies = [ "anyhow", "axum", @@ -2883,7 +2883,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.14" +version = "0.0.15" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index e82b144..032568a 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.14" +version = "0.0.15" 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 4da7445..f76662c 100644 --- a/warzone/crates/warzone-client/src/tui/app.rs +++ b/warzone/crates/warzone-client/src/tui/app.rs @@ -52,6 +52,7 @@ pub struct App { pub peer_fp: Option, pub server_url: String, pub should_quit: bool, + pub last_dm_peer: Option, /// Track receipt status for messages we sent, keyed by message ID. pub receipts: Arc>>, /// Pending incoming file transfers, keyed by file ID. @@ -111,6 +112,7 @@ impl App { peer_fp, server_url, should_quit: false, + last_dm_peer: None, receipts: Arc::new(Mutex::new(HashMap::new())), pending_files: Arc::new(Mutex::new(HashMap::new())), } @@ -266,6 +268,33 @@ impl App { self.list_aliases(client).await; return; } + if text == "/unalias" { + let url = format!("{}/v1/alias/unregister", client.base_url); + 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: "Alias removed".into(), 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 }), + } + return; + } + if text == "/r" || text == "/reply" { + // Just switch to last DM peer + if let Some(ref peer) = self.last_dm_peer.clone() { + self.peer_fp = Some(peer.clone()); + self.add_message(ChatLine { sender: "system".into(), text: format!("→ switched to {}", &peer[..peer.len().min(16)]), is_system: true, is_self: false, message_id: None }); + } else { + self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None }); + } + return; + } if text.starts_with("/peer ") { let raw = text[6..].trim().to_string(); let fp = if raw.starts_with('@') { diff --git a/warzone/crates/warzone-server/src/routes/aliases.rs b/warzone/crates/warzone-server/src/routes/aliases.rs index 37deb4d..884c2a9 100644 --- a/warzone/crates/warzone-server/src/routes/aliases.rs +++ b/warzone/crates/warzone-server/src/routes/aliases.rs @@ -21,6 +21,8 @@ pub fn routes() -> Router { .route("/alias/resolve/:name", get(resolve_alias)) .route("/alias/list", get(list_aliases)) .route("/alias/whois/:fingerprint", get(reverse_lookup)) + .route("/alias/unregister", post(unregister_alias)) + .route("/alias/admin-remove", post(admin_remove_alias)) } fn normalize_fp(fp: &str) -> String { @@ -337,3 +339,61 @@ async fn list_aliases( Ok(Json(serde_json::json!({ "aliases": aliases, "count": aliases.len() }))) } + +#[derive(Deserialize)] +struct UnregisterRequest { + fingerprint: String, +} + +/// Remove your own alias. +async fn unregister_alias( + State(state): State, + Json(req): Json, +) -> AppResult> { + let fp = normalize_fp(&req.fingerprint); + + let alias = match state.db.aliases.get(format!("fp:{}", fp).as_bytes())? { + Some(data) => String::from_utf8_lossy(&data).to_string(), + None => return Ok(Json(serde_json::json!({ "error": "no alias registered" }))), + }; + + if let Some(record) = load_alias_record(&state.db.aliases, &alias) { + if record.fingerprint != fp { + return Ok(Json(serde_json::json!({ "error": "not your alias" }))); + } + delete_alias_record(&state.db.aliases, &record)?; + tracing::info!("Alias '{}' unregistered by {}", alias, fp); + } + + Ok(Json(serde_json::json!({ "ok": true, "removed": alias }))) +} + +/// Admin password (set via WARZONE_ADMIN_PASSWORD env var, defaults to "admin"). +fn admin_password() -> String { + std::env::var("WARZONE_ADMIN_PASSWORD").unwrap_or_else(|_| "admin".to_string()) +} + +#[derive(Deserialize)] +struct AdminRemoveRequest { + alias: String, + admin_password: String, +} + +/// Admin: remove any alias. +async fn admin_remove_alias( + State(state): State, + Json(req): Json, +) -> AppResult> { + if req.admin_password != admin_password() { + return Ok(Json(serde_json::json!({ "error": "invalid admin password" }))); + } + + let alias = normalize_alias(&req.alias); + if let Some(record) = load_alias_record(&state.db.aliases, &alias) { + delete_alias_record(&state.db.aliases, &record)?; + tracing::info!("Alias '{}' removed by admin", alias); + Ok(Json(serde_json::json!({ "ok": true, "removed": alias }))) + } else { + Ok(Json(serde_json::json!({ "error": "alias not found" }))) + } +} diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 29c6061..7b309c1 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -236,7 +236,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.10'; +const VERSION = '0.0.15'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -505,6 +505,7 @@ async function handleIncomingMessage(bytes) { addMsg(fromLabel, result.text, false); // Send delivery receipt 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); @@ -539,6 +540,7 @@ async function handleIncomingMessage(bytes) { addMsg(fromLabel, result.text, false); // Send delivery receipt 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); @@ -633,6 +635,7 @@ async function doRecover() { } let currentGroup = null; // if set, messages go to group +let lastDmPeer = null; // for /r reply async function enterChat() { document.getElementById('setup').classList.remove('active'); @@ -809,7 +812,39 @@ async function doSend() { }); const data = await resp.json(); if (data.error) { addSys('Error: ' + data.error); } - else { addSys('Alias @' + data.alias + ' registered'); } + 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 '); 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(myFingerprint.slice(0, 19), replyText.trim(), true); + } catch(e) { addSys('Reply failed: ' + e.message); } return; } if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; }