diff --git a/warzone/crates/warzone-client/src/tui/app.rs b/warzone/crates/warzone-client/src/tui/app.rs index f86371e..3729e2d 100644 --- a/warzone/crates/warzone-client/src/tui/app.rs +++ b/warzone/crates/warzone-client/src/tui/app.rs @@ -54,7 +54,7 @@ impl App { } else { messages.lock().unwrap().push(ChatLine { sender: "system".into(), - text: "No peer set. Use /peer to start chatting".into(), + text: "No peer set. Use /peer , /peer @alias, or /g ".into(), is_system: true, is_self: false, }); @@ -62,7 +62,7 @@ impl App { messages.lock().unwrap().push(ChatLine { sender: "system".into(), - text: "Commands: /peer , /info, /quit".into(), + text: "Commands: /alias , /peer , /g , /info, /quit".into(), is_system: true, is_self: false, }); @@ -181,8 +181,25 @@ impl App { }); return; } + if text.starts_with("/alias ") { + let name = text[7..].trim(); + self.register_alias(name, client).await; + return; + } + if text == "/aliases" { + self.list_aliases(client).await; + return; + } if text.starts_with("/peer ") { - let fp = text[6..].trim().to_string(); + let raw = text[6..].trim().to_string(); + let fp = if raw.starts_with('@') { + match self.resolve_alias(&raw[1..], client).await { + Some(resolved) => resolved, + None => return, + } + } else { + raw + }; self.add_message(ChatLine { sender: "system".into(), text: format!("Peer set to {}", fp), @@ -544,6 +561,70 @@ impl App { } } } + + async fn register_alias(&self, name: &str, client: &ServerClient) { + let url = format!("{}/v1/alias/register", client.base_url); + match client.client.post(&url) + .json(&serde_json::json!({"alias": name, "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 alias = data.get("alias").and_then(|v| v.as_str()).unwrap_or(name); + self.add_message(ChatLine { sender: "system".into(), text: format!("Alias @{} registered", alias), 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 resolve_alias(&self, name: &str, client: &ServerClient) -> Option { + let url = format!("{}/v1/alias/resolve/{}", client.base_url, name); + match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) { + self.add_message(ChatLine { sender: "system".into(), text: format!("@{} → {}", name, fp), is_system: true, is_self: false }); + return Some(fp.to_string()); + } + if let Some(err) = data.get("error") { + self.add_message(ChatLine { sender: "system".into(), text: format!("Unknown alias @{}: {}", name, err), is_system: true, is_self: false }); + } + } + None + } + Err(e) => { + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }); + None + } + } + } + + async fn list_aliases(&self, client: &ServerClient) { + let url = format!("{}/v1/alias/list", client.base_url); + match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if let Some(aliases) = data.get("aliases").and_then(|v| v.as_array()) { + if aliases.is_empty() { + self.add_message(ChatLine { sender: "system".into(), text: "No aliases".into(), is_system: true, is_self: false }); + } else { + for a in aliases { + let name = a.get("alias").and_then(|v| v.as_str()).unwrap_or("?"); + let fp = a.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?"); + self.add_message(ChatLine { sender: "system".into(), text: format!(" @{} → {}...", name, &fp[..fp.len().min(16)]), 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 }), + } + } } fn normfp(fp: &str) -> String { diff --git a/warzone/crates/warzone-server/src/db.rs b/warzone/crates/warzone-server/src/db.rs index 0ce908f..425162d 100644 --- a/warzone/crates/warzone-server/src/db.rs +++ b/warzone/crates/warzone-server/src/db.rs @@ -4,6 +4,7 @@ pub struct Database { pub keys: sled::Tree, pub messages: sled::Tree, pub groups: sled::Tree, + pub aliases: sled::Tree, _db: sled::Db, } @@ -13,10 +14,12 @@ impl Database { let keys = db.open_tree("keys")?; let messages = db.open_tree("messages")?; let groups = db.open_tree("groups")?; + let aliases = db.open_tree("aliases")?; Ok(Database { keys, messages, groups, + aliases, _db: db, }) } diff --git a/warzone/crates/warzone-server/src/routes/aliases.rs b/warzone/crates/warzone-server/src/routes/aliases.rs new file mode 100644 index 0000000..618d060 --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/aliases.rs @@ -0,0 +1,127 @@ +use axum::{ + extract::{Path, State}, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; + +use crate::errors::AppResult; +use crate::state::AppState; + +pub fn routes() -> Router { + Router::new() + .route("/alias/register", post(register_alias)) + .route("/alias/resolve/:name", get(resolve_alias)) + .route("/alias/list", get(list_aliases)) + .route("/alias/whois/:fingerprint", get(reverse_lookup)) +} + +fn normalize_fp(fp: &str) -> String { + fp.chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase() +} + +fn normalize_alias(name: &str) -> String { + name.trim() + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-') + .collect() +} + +#[derive(Deserialize)] +struct RegisterRequest { + alias: String, + fingerprint: String, +} + +/// Register an alias. One alias per fingerprint, one fingerprint per alias. +async fn register_alias( + State(state): State, + Json(req): Json, +) -> AppResult> { + let alias = normalize_alias(&req.alias); + let fp = normalize_fp(&req.fingerprint); + + if alias.is_empty() || alias.len() > 32 { + return Ok(Json(serde_json::json!({ "error": "alias must be 1-32 alphanumeric chars" }))); + } + + // Check if alias is taken by someone else + if let Some(existing) = state.db.aliases.get(format!("a:{}", alias).as_bytes())? { + let existing_fp = String::from_utf8_lossy(&existing).to_string(); + if existing_fp != fp { + return Ok(Json(serde_json::json!({ "error": "alias already taken" }))); + } + // Same person re-registering — ok + } + + // Remove old alias for this fingerprint (one alias per person) + if let Some(old_alias) = state.db.aliases.get(format!("fp:{}", fp).as_bytes())? { + let old = String::from_utf8_lossy(&old_alias).to_string(); + state.db.aliases.remove(format!("a:{}", old).as_bytes())?; + tracing::info!("Removed old alias '{}' for {}", old, fp); + } + + // Store both directions: alias→fp and fp→alias + state.db.aliases.insert(format!("a:{}", alias).as_bytes(), fp.as_bytes())?; + state.db.aliases.insert(format!("fp:{}", fp).as_bytes(), alias.as_bytes())?; + state.db.aliases.flush()?; + + tracing::info!("Alias '{}' registered for {}", alias, fp); + Ok(Json(serde_json::json!({ "ok": true, "alias": alias, "fingerprint": fp }))) +} + +/// Resolve an alias to a fingerprint. +async fn resolve_alias( + State(state): State, + Path(name): Path, +) -> AppResult> { + let alias = normalize_alias(&name); + + match state.db.aliases.get(format!("a:{}", alias).as_bytes())? { + Some(data) => { + let fp = String::from_utf8_lossy(&data).to_string(); + Ok(Json(serde_json::json!({ "alias": alias, "fingerprint": fp }))) + } + None => Ok(Json(serde_json::json!({ "error": "alias not found" }))), + } +} + +/// Reverse lookup: fingerprint → alias. +async fn reverse_lookup( + State(state): State, + Path(fingerprint): Path, +) -> AppResult> { + let fp = normalize_fp(&fingerprint); + + match state.db.aliases.get(format!("fp:{}", fp).as_bytes())? { + Some(data) => { + let alias = String::from_utf8_lossy(&data).to_string(); + Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": alias }))) + } + None => Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": null }))), + } +} + +/// List all aliases. +async fn list_aliases( + State(state): State, +) -> AppResult> { + let aliases: Vec = state + .db + .aliases + .scan_prefix(b"a:") + .filter_map(|item| { + item.ok().map(|(k, v)| { + let alias = String::from_utf8_lossy(&k[2..]).to_string(); + let fp = String::from_utf8_lossy(&v).to_string(); + serde_json::json!({ "alias": alias, "fingerprint": fp }) + }) + }) + .collect(); + + Ok(Json(serde_json::json!({ "aliases": aliases, "count": aliases.len() }))) +} diff --git a/warzone/crates/warzone-server/src/routes/mod.rs b/warzone/crates/warzone-server/src/routes/mod.rs index 3d43206..cab6779 100644 --- a/warzone/crates/warzone-server/src/routes/mod.rs +++ b/warzone/crates/warzone-server/src/routes/mod.rs @@ -1,3 +1,4 @@ +mod aliases; mod groups; mod health; mod keys; @@ -14,6 +15,7 @@ pub fn router() -> Router { .merge(keys::routes()) .merge(messages::routes()) .merge(groups::routes()) + .merge(aliases::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 7144262..962f12d 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -301,7 +301,13 @@ async function pollMessages() { const aesKey = await fetchPeerKey(env.from); const ct = fromHex(env.ciphertext); const text = await aesDecrypt(aesKey, ct); - const fromLabel = formatFP(fromHex(env.from)).slice(0, 19); + let fromLabel = env.from.slice(0, 12); + // Try to resolve alias + try { + const ar = await fetch(SERVER + '/v1/alias/whois/' + env.from); + const ad = await ar.json(); + if (ad.alias) fromLabel = '@' + ad.alias; + } catch(e) {} const groupTag = env.group ? ' [' + env.group + ']' : ''; addMsg(fromLabel + groupTag, text, false); continue; @@ -383,9 +389,8 @@ async function enterChat() { await registerKey(); addSys('Identity loaded: ' + myFingerprint); addSys('Key registered with server'); - addSys('DM: paste peer fingerprint above'); - addSys('Groups: /gcreate · /gjoin · /g · /glist'); - addSys('Other: /info · /clear · /dm (switch back to DM mode)'); + addSys('DM: paste peer fingerprint or @alias above'); + addSys('/alias · /g · /glist · /info · /clear'); const savedPeer = localStorage.getItem('wz-peer'); if (savedPeer) $peerInput.value = savedPeer; @@ -489,12 +494,36 @@ async function doSend() { if (!text) return; // Commands - if (text === '/info') { addSys('Fingerprint: ' + myFingerprint); return; } + 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); + 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 === '/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'); } + 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; } @@ -509,10 +538,20 @@ async function doSend() { return; } - // DM - const peer = $peerInput.value.trim(); - if (!peer || peer.startsWith('#')) { addSys('Set a peer fingerprint or use /g '); return; } - localStorage.setItem('wz-peer', peer); + // DM — resolve @alias if needed + let peer = $peerInput.value.trim(); + if (!peer || peer.startsWith('#')) { addSys('Set a peer fingerprint/@alias or use /g '); return; } + + if (peer.startsWith('@')) { + const aliasName = peer.slice(1); + const resp = await fetch(SERVER + '/v1/alias/resolve/' + aliasName); + const data = await resp.json(); + if (data.error) { addSys('Unknown alias @' + aliasName); return; } + peer = data.fingerprint; + addSys('Resolved @' + aliasName + ' → ' + peer.slice(0,16) + '...'); + } + + localStorage.setItem('wz-peer', $peerInput.value.trim()); try { await sendEncrypted(peer, text);