v0.0.21: TUI overhaul, WZP call infrastructure, security hardening, federation
TUI:
- Split 1,756-line app.rs monolith into 7 modules (types, draw, commands, input, file_transfer, network, mod)
- Message timestamps [HH:MM], scrolling (PageUp/Down/arrows), connection status dot, unread badge
- /help command, terminal bell on incoming DM, /devices + /kick commands
- 44 unit tests (types, input, draw with TestBackend)
Server — WZP Call Infrastructure (FC-2/3/5/6/7/10):
- Call state management (CallState, CallStatus, active_calls, calls + missed_calls sled trees)
- WS call signal awareness (Offer/Answer/Hangup update state, missed call on offline)
- Group call endpoint (POST /groups/:name/call with SHA-256 room ID, fan-out)
- Presence API (GET /presence/:fp, POST /presence/batch)
- Missed call flush on WS reconnect
- WZP relay config + CORS
Server — Security (FC-P1):
- Auth enforcement middleware (AuthFingerprint extractor on 13 write handlers)
- Session auto-recovery (delete corrupted ratchet, show [session reset])
- WS connection cap (5/fingerprint) + global concurrency limit (200)
- Device management (GET /devices, POST /devices/:id/kick, POST /devices/revoke-all)
Server — Federation:
- Two-server federation via JSON config (--federation flag)
- Periodic presence sync (every 5s, full-state, self-healing)
- Message forwarding via HTTP POST with SHA-256(secret||body) auth
- Graceful degradation (peer down = queue locally)
- deliver_or_queue() replaces push-or-queue in ws.rs + messages.rs
Client — Group Messaging:
- SenderKeyDistribution storage + GroupSenderKey decryption in TUI
- sender_keys sled tree in LocalDb
WASM:
- All 8 WireMessage variants handled (no more "unsupported")
- decrypt_group_message() + create_sender_key_from_distribution() exports
- CallSignal parsing with signal_type mapping
Docs:
- ARCHITECTURE.md rewritten with Mermaid diagrams
- README.md created
- TASK_PLAN.md with FC-P{phase}-T{task} naming
- PROGRESS.md updated to v0.0.21
WZP submodule updated to 6f4e8eb (IAX2 trunking, adaptive quality, metrics, all S-tasks done)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ pub struct LocalDb {
|
||||
pre_keys: sled::Tree,
|
||||
contacts: sled::Tree,
|
||||
history: sled::Tree,
|
||||
sender_keys: sled::Tree,
|
||||
_db: sled::Db,
|
||||
}
|
||||
|
||||
@@ -39,11 +40,13 @@ impl LocalDb {
|
||||
let pre_keys = db.open_tree("pre_keys")?;
|
||||
let contacts = db.open_tree("contacts")?;
|
||||
let history = db.open_tree("history")?;
|
||||
let sender_keys = db.open_tree("sender_keys")?;
|
||||
Ok(LocalDb {
|
||||
sessions,
|
||||
pre_keys,
|
||||
contacts,
|
||||
history,
|
||||
sender_keys,
|
||||
_db: db,
|
||||
})
|
||||
}
|
||||
@@ -57,6 +60,14 @@ impl LocalDb {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a ratchet session for a peer (used for session recovery).
|
||||
pub fn delete_session(&self, peer: &Fingerprint) -> Result<()> {
|
||||
let key = peer.to_hex();
|
||||
self.sessions.remove(key.as_bytes())?;
|
||||
self.sessions.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a ratchet session for a peer.
|
||||
pub fn load_session(&self, peer: &Fingerprint) -> Result<Option<RatchetState>> {
|
||||
let key = peer.to_hex();
|
||||
@@ -115,6 +126,39 @@ impl LocalDb {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sender Keys ──
|
||||
|
||||
/// Save a sender key for a (sender, group) pair.
|
||||
pub fn save_sender_key(
|
||||
&self,
|
||||
sender_fp: &str,
|
||||
group_name: &str,
|
||||
key: &warzone_protocol::sender_keys::SenderKey,
|
||||
) -> Result<()> {
|
||||
let db_key = format!("sk:{}:{}", sender_fp, group_name);
|
||||
let data = bincode::serialize(key).context("failed to serialize sender key")?;
|
||||
self.sender_keys.insert(db_key.as_bytes(), data)?;
|
||||
self.sender_keys.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a sender key for a (sender, group) pair.
|
||||
pub fn load_sender_key(
|
||||
&self,
|
||||
sender_fp: &str,
|
||||
group_name: &str,
|
||||
) -> Result<Option<warzone_protocol::sender_keys::SenderKey>> {
|
||||
let db_key = format!("sk:{}:{}", sender_fp, group_name);
|
||||
match self.sender_keys.get(db_key.as_bytes())? {
|
||||
Some(data) => {
|
||||
let key = bincode::deserialize(&data)
|
||||
.context("failed to deserialize sender key")?;
|
||||
Ok(Some(key))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contacts ──
|
||||
|
||||
/// Add or update a contact. Called on send/receive.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
798
warzone/crates/warzone-client/src/tui/commands.rs
Normal file
798
warzone/crates/warzone-client/src/tui/commands.rs
Normal file
@@ -0,0 +1,798 @@
|
||||
use warzone_protocol::identity::IdentityKeyPair;
|
||||
use warzone_protocol::message::WireMessage;
|
||||
use warzone_protocol::ratchet::RatchetState;
|
||||
use warzone_protocol::types::Fingerprint;
|
||||
use warzone_protocol::x3dh;
|
||||
use x25519_dalek::PublicKey;
|
||||
|
||||
use crate::net::ServerClient;
|
||||
use crate::storage::LocalDb;
|
||||
|
||||
use chrono::Local;
|
||||
|
||||
use super::types::{App, ChatLine, ReceiptStatus, normfp};
|
||||
|
||||
impl App {
|
||||
pub async fn handle_send(
|
||||
&mut self,
|
||||
identity: &IdentityKeyPair,
|
||||
db: &LocalDb,
|
||||
client: &ServerClient,
|
||||
) {
|
||||
let text = self.input.trim().to_string();
|
||||
self.input.clear();
|
||||
self.cursor_pos = 0;
|
||||
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Commands
|
||||
if text == "/quit" || text == "/q" {
|
||||
self.should_quit = true;
|
||||
return;
|
||||
}
|
||||
if text == "/info" {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Your fingerprint: {}", self.our_fp),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if text == "/help" || text == "/?" {
|
||||
let help_lines = [
|
||||
"Commands:",
|
||||
" /help, /? Show this help",
|
||||
" /info Show your fingerprint",
|
||||
" /eth Show Ethereum address",
|
||||
" /peer <fp>, /p Set DM peer by fingerprint",
|
||||
" /peer @alias Set DM peer by alias",
|
||||
" /reply, /r Reply to last DM sender",
|
||||
" /dm Switch to DM mode (clear peer)",
|
||||
" /contacts, /c List contacts with message counts",
|
||||
" /history, /h [fp] Show conversation history",
|
||||
" /alias <name> Register an alias for yourself",
|
||||
" /aliases List all registered aliases",
|
||||
" /unalias Remove your alias",
|
||||
" /devices List your active device sessions",
|
||||
" /kick <device_id> Kick a specific device session",
|
||||
" /g <name> Switch to group (auto-join)",
|
||||
" /gcreate <name> Create a new group",
|
||||
" /gjoin <name> Join a group",
|
||||
" /glist List all groups",
|
||||
" /gleave Leave current group",
|
||||
" /gkick <fp> Kick member from group",
|
||||
" /gmembers List group members",
|
||||
" /file <path> Send a file (max 10MB)",
|
||||
" /quit, /q Exit",
|
||||
"",
|
||||
"Navigation:",
|
||||
" PageUp/PageDown Scroll messages",
|
||||
" Up/Down Scroll by 1 (when input empty)",
|
||||
" Ctrl+C, Esc Quit",
|
||||
];
|
||||
for line in &help_lines {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: line.to_string(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
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 == "/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::<serde_json::Value>().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, timestamp: Local::now() });
|
||||
} else {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: "Alias removed".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
}
|
||||
},
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if text == "/contacts" || text == "/c" {
|
||||
match db.list_contacts() {
|
||||
Ok(contacts) => {
|
||||
if contacts.is_empty() {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: "No contacts yet".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
} else {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("Contacts ({}):", contacts.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
for c in &contacts {
|
||||
let fp = c.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let alias = c.get("alias").and_then(|v| v.as_str());
|
||||
let count = c.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let label = match alias {
|
||||
Some(a) => format!(" @{} ({}) — {} msgs", a, &fp[..fp.len().min(12)], count),
|
||||
None => format!(" {} — {} msgs", &fp[..fp.len().min(16)], count),
|
||||
};
|
||||
self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if text.starts_with("/history") || text.starts_with("/h ") {
|
||||
let peer = if text.starts_with("/h ") { text[3..].trim() } else if text.starts_with("/history ") { text[9..].trim() } else { "" };
|
||||
let fp = if let Some(ref p) = self.peer_fp { if !p.starts_with('#') { p.as_str() } else { peer } } else { peer };
|
||||
if fp.is_empty() {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /history or /h <fingerprint> (or set peer first)".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
} else {
|
||||
match db.get_history(fp, 50) {
|
||||
Ok(msgs) => {
|
||||
if msgs.is_empty() {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: "No history with this peer".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
} else {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("History ({} messages):", msgs.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
for m in &msgs {
|
||||
let sender = m.get("sender").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let txt = m.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let is_self = m.get("is_self").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
self.add_message(ChatLine {
|
||||
sender: sender[..sender.len().min(12)].to_string(),
|
||||
text: txt.to_string(),
|
||||
is_system: false,
|
||||
is_self,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if text == "/eth" {
|
||||
// Show ethereum address from seed
|
||||
if let Ok(seed) = crate::keystore::load_seed_raw() {
|
||||
let eth = warzone_protocol::ethereum::derive_eth_identity(&seed);
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("ETH: {}", eth.address.to_checksum()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if text == "/devices" {
|
||||
let url = format!("{}/v1/devices", client.base_url);
|
||||
// Try to get bearer token from a recent auth (for now, make unauthenticated GET)
|
||||
match client.client.get(&url).send().await {
|
||||
Ok(resp) => {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(devices) = data.get("devices").and_then(|v| v.as_array()) {
|
||||
if devices.is_empty() {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: "No active devices".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
} else {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("Active devices ({}):", devices.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
for d in devices {
|
||||
let id = d.get("device_id").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let connected = d.get("connected_at").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let when = chrono::DateTime::from_timestamp(connected, 0)
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!(" {} — connected {}", id, when), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
} else 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, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if text.starts_with("/kick ") {
|
||||
let device_id = text[6..].trim();
|
||||
if device_id.is_empty() {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /kick <device_id>".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
return;
|
||||
}
|
||||
let url = format!("{}/v1/devices/{}/kick", client.base_url, device_id);
|
||||
match client.client.post(&url).json(&serde_json::json!({})).send().await {
|
||||
Ok(resp) => {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(kicked) = data.get("kicked").and_then(|v| v.as_str()) {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("Device {} kicked", kicked), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
} else 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, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if text == "/r" || text == "/reply" {
|
||||
let last = self.last_dm_peer.lock().unwrap().clone();
|
||||
if let Some(ref peer) = last {
|
||||
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, timestamp: Local::now() });
|
||||
} else {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if text.starts_with("/peer ") || text.starts_with("/p ") {
|
||||
let text = if text.starts_with("/p ") { format!("/peer {}", &text[3..]) } else { text.clone() };
|
||||
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),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
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();
|
||||
// Auto-join
|
||||
self.group_join(&name, client).await;
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Switched to group #{}", name),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
self.peer_fp = Some(format!("#{}", name));
|
||||
return;
|
||||
}
|
||||
if text == "/dm" {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Switched to DM mode. Use /peer <fp>".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
self.peer_fp = None;
|
||||
return;
|
||||
}
|
||||
if text == "/glist" {
|
||||
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 <name> first".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
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, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
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, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if text.starts_with("/file ") {
|
||||
let path_str = text[6..].trim();
|
||||
self.handle_file_send(path_str, identity, db, client).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
sender: "system".into(),
|
||||
text: "No peer set. Use /peer <fingerprint>".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let peer_fp = match Fingerprint::from_hex(&peer) {
|
||||
Ok(fp) => fp,
|
||||
Err(_) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Invalid peer fingerprint".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let msg_id = uuid::Uuid::new_v4().to_string();
|
||||
let our_pub = identity.public_identity();
|
||||
let mut ratchet = db.load_session(&peer_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(&peer_fp, state);
|
||||
WireMessage::Message {
|
||||
id: msg_id.clone(),
|
||||
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||
ratchet_message: encrypted,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Encrypt failed: {}", e),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// X3DH
|
||||
let bundle = match client.fetch_bundle(&peer).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Failed to fetch bundle: {}", e),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let x3dh_result = match x3dh::initiate(identity, &bundle) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("X3DH failed: {}", e),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
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(&peer_fp, &state);
|
||||
WireMessage::KeyExchange {
|
||||
id: msg_id.clone(),
|
||||
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(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Encrypt failed: {}", e),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let encoded = match bincode::serialize(&wire_msg) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Serialize failed: {}", e),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match client.send_message(&peer, Some(&self.our_fp), &encoded).await {
|
||||
Ok(_) => {
|
||||
// Track receipt status
|
||||
self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent);
|
||||
// Store in contacts + history
|
||||
let _ = db.touch_contact(&peer, None);
|
||||
let _ = db.store_message(&peer, &self.our_fp, &text, true);
|
||||
self.add_message(ChatLine {
|
||||
sender: self.our_fp[..12].to_string(),
|
||||
text: text.clone(),
|
||||
is_system: false,
|
||||
is_self: true,
|
||||
message_id: Some(msg_id), timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Send failed: {}", e),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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::<serde_json::Value>().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, timestamp: Local::now() });
|
||||
} else {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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::<serde_json::Value>().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, timestamp: Local::now() });
|
||||
} 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, message_id: None, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||
}
|
||||
}
|
||||
|
||||
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::<serde_json::Value>().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, message_id: None, timestamp: Local::now() });
|
||||
} 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, message_id: None, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||
}
|
||||
}
|
||||
|
||||
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::<serde_json::Value>().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, timestamp: Local::now() });
|
||||
} else {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("Left group '{}'", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||
}
|
||||
}
|
||||
|
||||
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::<serde_json::Value>().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, timestamp: Local::now() });
|
||||
} 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, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||
}
|
||||
}
|
||||
|
||||
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::<serde_json::Value>().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, timestamp: Local::now() });
|
||||
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, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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::<serde_json::Value>().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); return; }
|
||||
},
|
||||
Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); return; }
|
||||
};
|
||||
|
||||
let my_fp = normfp(&self.our_fp);
|
||||
let members: Vec<String> = 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<serde_json::Value> = 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 {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
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 {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
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, message_id: None, timestamp: Local::now() });
|
||||
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,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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::<serde_json::Value>().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, timestamp: Local::now() });
|
||||
} 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, message_id: None, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_alias(&self, name: &str, client: &ServerClient) -> Option<String> {
|
||||
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::<serde_json::Value>().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, message_id: None, timestamp: Local::now() });
|
||||
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, message_id: None, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
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::<serde_json::Value>().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, message_id: None, timestamp: Local::now() });
|
||||
} 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, message_id: None, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
377
warzone/crates/warzone-client/src/tui/draw.rs
Normal file
377
warzone/crates/warzone-client/src/tui/draw.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use super::types::{App, ReceiptStatus};
|
||||
|
||||
impl App {
|
||||
fn receipt_indicator(&self, message_id: &Option<String>) -> &'static str {
|
||||
match message_id {
|
||||
Some(id) => {
|
||||
let receipts = self.receipts.lock().unwrap();
|
||||
match receipts.get(id) {
|
||||
Some(ReceiptStatus::Read) => " \u{2713}\u{2713}", // ✓✓ (read)
|
||||
Some(ReceiptStatus::Delivered) => " \u{2713}\u{2713}", // ✓✓ (delivered)
|
||||
Some(ReceiptStatus::Sent) | None => " \u{2713}", // ✓ (sent)
|
||||
}
|
||||
}
|
||||
None => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn receipt_color(&self, message_id: &Option<String>) -> Color {
|
||||
match message_id {
|
||||
Some(id) => {
|
||||
let receipts = self.receipts.lock().unwrap();
|
||||
match receipts.get(id) {
|
||||
Some(ReceiptStatus::Read) => Color::Blue,
|
||||
Some(ReceiptStatus::Delivered) => Color::White,
|
||||
Some(ReceiptStatus::Sent) | None => Color::DarkGray,
|
||||
}
|
||||
}
|
||||
None => Color::DarkGray,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(&self, frame: &mut Frame) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(5), // messages
|
||||
Constraint::Length(3), // input
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
// Header
|
||||
let peer_str = self
|
||||
.peer_fp
|
||||
.as_deref()
|
||||
.unwrap_or("no peer");
|
||||
let is_connected = self.connected.load(Ordering::Relaxed);
|
||||
let (conn_indicator, conn_color) = if is_connected {
|
||||
(" \u{25CF}", Color::Green) // ●
|
||||
} else {
|
||||
(" \u{25CF}", Color::Red) // ●
|
||||
};
|
||||
let header = Paragraph::new(Line::from(vec![
|
||||
Span::styled("WZ ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(&self.our_fp, Style::default().fg(Color::Green)),
|
||||
Span::raw(" \u{2192} "),
|
||||
Span::styled(peer_str, Style::default().fg(Color::Yellow)),
|
||||
Span::styled(
|
||||
format!(" [{}]", self.server_url),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::styled(conn_indicator, Style::default().fg(conn_color)),
|
||||
]));
|
||||
frame.render_widget(header, chunks[0]);
|
||||
|
||||
// Messages
|
||||
let msgs = self.messages.lock().unwrap();
|
||||
let items: Vec<ListItem> = msgs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let style = if m.is_system {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else if m.is_self {
|
||||
Style::default().fg(Color::Green)
|
||||
} else {
|
||||
Style::default().fg(Color::Yellow)
|
||||
};
|
||||
|
||||
let timestamp = format!("[{}] ", m.timestamp.format("%H:%M"));
|
||||
|
||||
let prefix = if m.is_system {
|
||||
"*** ".to_string()
|
||||
} else {
|
||||
format!("{}: ", &m.sender[..m.sender.len().min(12)])
|
||||
};
|
||||
|
||||
let receipt_str = if m.is_self && m.message_id.is_some() {
|
||||
self.receipt_indicator(&m.message_id)
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let receipt_color = self.receipt_color(&m.message_id);
|
||||
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(timestamp, Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(prefix, style.add_modifier(Modifier::BOLD)),
|
||||
Span::raw(&m.text),
|
||||
Span::styled(receipt_str, Style::default().fg(receipt_color)),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Scroll support: compute the visible window of items
|
||||
let visible_height = chunks[1].height.saturating_sub(1) as usize; // minus top border
|
||||
let total = items.len();
|
||||
let end = total.saturating_sub(self.scroll_offset);
|
||||
let start = end.saturating_sub(visible_height);
|
||||
let visible_items = if total == 0 {
|
||||
vec![]
|
||||
} else {
|
||||
items[start..end].to_vec()
|
||||
};
|
||||
|
||||
let messages_widget = List::new(visible_items)
|
||||
.block(Block::default().borders(Borders::TOP));
|
||||
frame.render_widget(messages_widget, chunks[1]);
|
||||
|
||||
// Input
|
||||
let input_title = if self.scroll_offset > 0 {
|
||||
format!(" [{} new \u{2193}] ", self.scroll_offset)
|
||||
} else {
|
||||
" message ".to_string()
|
||||
};
|
||||
let input_widget = Paragraph::new(self.input.as_str())
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::DarkGray))
|
||||
.title(input_title),
|
||||
)
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(input_widget, chunks[2]);
|
||||
|
||||
// Cursor
|
||||
let x = (self.cursor_pos as u16 + 1).min(chunks[2].width - 2);
|
||||
frame.set_cursor_position((chunks[2].x + x, chunks[2].y + 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use chrono::Local;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
use super::super::types::{App, ChatLine};
|
||||
|
||||
/// Helper: collect the entire terminal buffer into a single String.
|
||||
fn full_buffer_text(terminal: &Terminal<TestBackend>) -> String {
|
||||
let buf = terminal.backend().buffer();
|
||||
(0..buf.area().height)
|
||||
.flat_map(|y| {
|
||||
(0..buf.area().width).map(move |x| {
|
||||
buf.cell((x, y))
|
||||
.map(|c| c.symbol().chars().next().unwrap_or(' '))
|
||||
.unwrap_or(' ')
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Helper: check whether the buffer contains `needle`.
|
||||
fn buffer_contains(terminal: &Terminal<TestBackend>, needle: &str) -> bool {
|
||||
full_buffer_text(terminal).contains(needle)
|
||||
}
|
||||
|
||||
/// Helper: collect a single row into a String.
|
||||
fn row_text(terminal: &Terminal<TestBackend>, row: u16) -> String {
|
||||
let buf = terminal.backend().buffer();
|
||||
(0..buf.area().width)
|
||||
.map(|x| {
|
||||
buf.cell((x, row))
|
||||
.map(|c| c.symbol().chars().next().unwrap_or(' '))
|
||||
.unwrap_or(' ')
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn make_app() -> App {
|
||||
App::new("aabbcc".into(), Some("ddeeff".into()), "localhost:7700".into())
|
||||
}
|
||||
|
||||
fn make_terminal() -> Terminal<TestBackend> {
|
||||
let backend = TestBackend::new(80, 24);
|
||||
Terminal::new(backend).expect("terminal creation should succeed")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 1. draw_does_not_panic
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn draw_does_not_panic() {
|
||||
let app = make_app();
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).expect("draw should not fail");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 2. header_contains_fingerprint
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn header_contains_fingerprint() {
|
||||
let app = make_app();
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
let header = row_text(&terminal, 0);
|
||||
assert!(
|
||||
header.contains("aabbcc"),
|
||||
"header should contain our fingerprint 'aabbcc', got: {header}"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 3. connection_indicator_red_when_disconnected
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn connection_indicator_red_when_disconnected() {
|
||||
let app = make_app();
|
||||
// connected defaults to false
|
||||
assert!(!app.connected.load(Ordering::Relaxed));
|
||||
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
let header = row_text(&terminal, 0);
|
||||
assert!(
|
||||
header.contains('\u{25CF}'),
|
||||
"header should contain the dot character when disconnected, got: {header}"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 4. connection_indicator_green_when_connected
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn connection_indicator_green_when_connected() {
|
||||
let app = make_app();
|
||||
app.connected.store(true, Ordering::Relaxed);
|
||||
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
let header = row_text(&terminal, 0);
|
||||
assert!(
|
||||
header.contains('\u{25CF}'),
|
||||
"header should contain the dot character when connected, got: {header}"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 5. timestamp_format_in_messages
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn timestamp_format_in_messages() {
|
||||
let app = make_app();
|
||||
app.add_message(ChatLine {
|
||||
sender: "alice".into(),
|
||||
text: "hello world".into(),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
let text = full_buffer_text(&terminal);
|
||||
// Timestamps are rendered as [HH:MM] — look for the bracket pattern.
|
||||
assert!(
|
||||
text.contains('[') && text.contains(']'),
|
||||
"buffer should contain timestamp brackets, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 6. scroll_offset_zero_shows_latest_messages
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn scroll_offset_zero_shows_latest_messages() {
|
||||
let app = make_app();
|
||||
for i in 0..30 {
|
||||
app.add_message(ChatLine {
|
||||
sender: "bot".into(),
|
||||
text: format!("msg-{i:03}"),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
// scroll_offset defaults to 0 — pinned to bottom.
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
assert!(
|
||||
buffer_contains(&terminal, "msg-029"),
|
||||
"the last message should be visible when scroll_offset is 0"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 7. scroll_offset_hides_latest_messages
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn scroll_offset_hides_latest_messages() {
|
||||
let mut app = make_app();
|
||||
for i in 0..30 {
|
||||
app.add_message(ChatLine {
|
||||
sender: "bot".into(),
|
||||
text: format!("msg-{i:03}"),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
app.scroll_offset = 10;
|
||||
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
assert!(
|
||||
!buffer_contains(&terminal, "msg-029"),
|
||||
"the last message should NOT be visible when scroll_offset=10"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 8. unread_badge_shows_when_scrolled
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn unread_badge_shows_when_scrolled() {
|
||||
let mut app = make_app();
|
||||
app.scroll_offset = 5;
|
||||
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
assert!(
|
||||
buffer_contains(&terminal, "new"),
|
||||
"buffer should contain 'new' from the unread badge when scrolled"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 9. no_unread_badge_at_bottom
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn no_unread_badge_at_bottom() {
|
||||
let app = make_app();
|
||||
// scroll_offset is 0 by default
|
||||
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
assert!(
|
||||
buffer_contains(&terminal, "message"),
|
||||
"buffer should contain the default title 'message' when not scrolled"
|
||||
);
|
||||
assert!(
|
||||
!full_buffer_text(&terminal).contains("new \u{2193}"),
|
||||
"buffer should NOT contain 'new ↓' when scroll_offset is 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
292
warzone/crates/warzone-client/src/tui/file_transfer.rs
Normal file
292
warzone/crates/warzone-client/src/tui/file_transfer.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sha2::{Sha256, Digest};
|
||||
use warzone_protocol::identity::IdentityKeyPair;
|
||||
use warzone_protocol::message::WireMessage;
|
||||
use warzone_protocol::types::Fingerprint;
|
||||
|
||||
use crate::net::ServerClient;
|
||||
use crate::storage::LocalDb;
|
||||
|
||||
use chrono::Local;
|
||||
|
||||
use super::types::{App, ChatLine, normfp, MAX_FILE_SIZE, CHUNK_SIZE};
|
||||
|
||||
impl App {
|
||||
pub async fn handle_file_send(
|
||||
&mut self,
|
||||
path_str: &str,
|
||||
identity: &IdentityKeyPair,
|
||||
db: &LocalDb,
|
||||
client: &ServerClient,
|
||||
) {
|
||||
let path = PathBuf::from(path_str);
|
||||
if !path.exists() {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("File not found: {}", path_str),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let metadata = match std::fs::metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Cannot read file: {}", e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let file_size = metadata.len();
|
||||
if file_size > MAX_FILE_SIZE {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("File too large: {} bytes (max {} bytes)", file_size, MAX_FILE_SIZE),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let file_data = match std::fs::read(&path) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Failed to read file: {}", e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let filename = path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unnamed".to_string());
|
||||
|
||||
// SHA-256 of the complete file
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&file_data);
|
||||
let sha256 = format!("{:x}", hasher.finalize());
|
||||
|
||||
let file_id = uuid::Uuid::new_v4().to_string();
|
||||
let total_chunks = ((file_data.len() + CHUNK_SIZE - 1) / CHUNK_SIZE) as u32;
|
||||
|
||||
// Resolve peer (or group members)
|
||||
let peer = match &self.peer_fp {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Set a peer or group first".into(),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Group file transfer: send to each member
|
||||
if peer.starts_with('#') {
|
||||
let group_name = &peer[1..];
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Sending '{}' to group #{}...", filename, group_name),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
|
||||
// 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::<serde_json::Value>().await {
|
||||
Ok(d) => d,
|
||||
Err(_) => return,
|
||||
},
|
||||
Err(_) => return,
|
||||
};
|
||||
let my_fp = normfp(&self.our_fp);
|
||||
let members: Vec<String> = 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();
|
||||
|
||||
for member in &members {
|
||||
if *member == my_fp { continue; }
|
||||
// Send file header + chunks to each member via HTTP
|
||||
let header = WireMessage::FileHeader {
|
||||
id: file_id.clone(),
|
||||
sender_fingerprint: self.our_fp.clone(),
|
||||
filename: filename.clone(),
|
||||
file_size,
|
||||
total_chunks,
|
||||
sha256: sha256.clone(),
|
||||
};
|
||||
if let Ok(encoded) = bincode::serialize(&header) {
|
||||
let _ = client.send_message(member, Some(&self.our_fp), &encoded).await;
|
||||
}
|
||||
for i in 0..total_chunks {
|
||||
let start = i as usize * CHUNK_SIZE;
|
||||
let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len());
|
||||
let chunk_msg = WireMessage::FileChunk {
|
||||
id: file_id.clone(),
|
||||
sender_fingerprint: self.our_fp.clone(),
|
||||
filename: filename.clone(),
|
||||
chunk_index: i,
|
||||
total_chunks,
|
||||
data: file_data[start..end].to_vec(),
|
||||
};
|
||||
if let Ok(encoded) = bincode::serialize(&chunk_msg) {
|
||||
let _ = client.send_message(member, Some(&self.our_fp), &encoded).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("File '{}' sent to group #{}", filename, group_name),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
let peer_fp = match Fingerprint::from_hex(&peer) {
|
||||
Ok(fp) => fp,
|
||||
Err(_) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Invalid peer fingerprint".into(),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let our_pub = identity.public_identity();
|
||||
let our_fp_str = our_pub.fingerprint.to_string();
|
||||
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Sending file '{}' ({} bytes, {} chunks)...", filename, file_size, total_chunks),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
|
||||
// Send FileHeader (unencrypted metadata — the chunks carry ratchet-encrypted data)
|
||||
let header = WireMessage::FileHeader {
|
||||
id: file_id.clone(),
|
||||
sender_fingerprint: our_fp_str.clone(),
|
||||
filename: filename.clone(),
|
||||
file_size,
|
||||
total_chunks,
|
||||
sha256: sha256.clone(),
|
||||
};
|
||||
|
||||
let encoded_header = match bincode::serialize(&header) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Serialize header failed: {}", e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded_header).await {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Failed to send file header: {}", e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Send each chunk: encrypt chunk data with ratchet, wrap in FileChunk
|
||||
for i in 0..total_chunks {
|
||||
let start = i as usize * CHUNK_SIZE;
|
||||
let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len());
|
||||
let chunk_data = &file_data[start..end];
|
||||
|
||||
// Encrypt chunk data with ratchet
|
||||
let mut ratchet = db.load_session(&peer_fp).ok().flatten();
|
||||
let encrypted_data = if let Some(ref mut state) = ratchet {
|
||||
match state.encrypt(chunk_data) {
|
||||
Ok(encrypted) => {
|
||||
let _ = db.save_session(&peer_fp, state);
|
||||
match bincode::serialize(&encrypted) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Serialize chunk failed: {}", e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Encrypt chunk {} failed: {}", i, e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "No ratchet session. Send a text message first to establish one.".into(),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
let chunk_msg = WireMessage::FileChunk {
|
||||
id: file_id.clone(),
|
||||
sender_fingerprint: our_fp_str.clone(),
|
||||
filename: filename.clone(),
|
||||
chunk_index: i,
|
||||
total_chunks,
|
||||
data: encrypted_data,
|
||||
};
|
||||
|
||||
let encoded = match bincode::serialize(&chunk_msg) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Serialize chunk {} failed: {}", i, e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded).await {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Failed to send chunk {}/{}: {}", i + 1, total_chunks, e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Sent chunk [{}/{}] of {}", i + 1, total_chunks, filename),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
|
||||
self.add_message(ChatLine {
|
||||
sender: self.our_fp[..12.min(self.our_fp.len())].to_string(),
|
||||
text: format!("Sent file: {} ({} bytes)", filename, file_size),
|
||||
is_system: false, is_self: true, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
377
warzone/crates/warzone-client/src/tui/input.rs
Normal file
377
warzone/crates/warzone-client/src/tui/input.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use super::types::App;
|
||||
|
||||
impl App {
|
||||
/// Handle a single key event. Returns true if the event was consumed.
|
||||
pub fn handle_key_event(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
// Alt+Backspace: delete word before cursor
|
||||
KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||
if self.cursor_pos > 0 {
|
||||
let before = &self.input[..self.cursor_pos];
|
||||
let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0);
|
||||
self.input.drain(new_pos..self.cursor_pos);
|
||||
self.cursor_pos = new_pos;
|
||||
}
|
||||
}
|
||||
// Backspace: delete char before cursor
|
||||
KeyCode::Backspace => {
|
||||
if self.cursor_pos > 0 {
|
||||
self.input.remove(self.cursor_pos - 1);
|
||||
self.cursor_pos -= 1;
|
||||
}
|
||||
}
|
||||
// Delete: delete char at cursor
|
||||
KeyCode::Delete => {
|
||||
if self.cursor_pos < self.input.len() {
|
||||
self.input.remove(self.cursor_pos);
|
||||
}
|
||||
}
|
||||
// Left arrow
|
||||
KeyCode::Left => {
|
||||
if key.modifiers.contains(KeyModifiers::ALT) {
|
||||
// Alt+Left: word left
|
||||
let before = &self.input[..self.cursor_pos];
|
||||
self.cursor_pos = before.rfind(' ').unwrap_or(0);
|
||||
} else if self.cursor_pos > 0 {
|
||||
self.cursor_pos -= 1;
|
||||
}
|
||||
}
|
||||
// Right arrow
|
||||
KeyCode::Right => {
|
||||
if key.modifiers.contains(KeyModifiers::ALT) {
|
||||
// Alt+Right: word right
|
||||
let after = &self.input[self.cursor_pos..];
|
||||
self.cursor_pos += after.find(' ').map(|i| i + 1).unwrap_or(after.len());
|
||||
} else if self.cursor_pos < self.input.len() {
|
||||
self.cursor_pos += 1;
|
||||
}
|
||||
}
|
||||
// Home / Ctrl+A
|
||||
KeyCode::Home => { self.cursor_pos = 0; }
|
||||
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.cursor_pos = 0;
|
||||
}
|
||||
// End: cursor to end of input when typing, snap to bottom when input is empty.
|
||||
// Ctrl+End always snaps to bottom.
|
||||
KeyCode::End => {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
// Ctrl+End: always snap scroll to bottom
|
||||
self.scroll_offset = 0;
|
||||
} else if self.input.is_empty() {
|
||||
// Plain End with empty input: snap scroll to bottom
|
||||
self.scroll_offset = 0;
|
||||
} else {
|
||||
// Plain End with text: move cursor to end of input
|
||||
self.cursor_pos = self.input.len();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.cursor_pos = self.input.len();
|
||||
}
|
||||
// Ctrl+U: clear line
|
||||
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.input.clear();
|
||||
self.cursor_pos = 0;
|
||||
}
|
||||
// Ctrl+K: kill to end of line
|
||||
KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.input.truncate(self.cursor_pos);
|
||||
}
|
||||
// Ctrl+W: delete word back
|
||||
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
let before = &self.input[..self.cursor_pos];
|
||||
let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0);
|
||||
self.input.drain(new_pos..self.cursor_pos);
|
||||
self.cursor_pos = new_pos;
|
||||
}
|
||||
// PageUp: scroll up by 10 messages
|
||||
KeyCode::PageUp => {
|
||||
let max = self.messages.lock().unwrap().len().saturating_sub(1);
|
||||
self.scroll_offset = (self.scroll_offset + 10).min(max);
|
||||
}
|
||||
// PageDown: scroll down by 10 messages
|
||||
KeyCode::PageDown => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(10);
|
||||
}
|
||||
// Up arrow: scroll up by 1 (only when input is empty)
|
||||
KeyCode::Up if self.input.is_empty() => {
|
||||
let max = self.messages.lock().unwrap().len().saturating_sub(1);
|
||||
self.scroll_offset = (self.scroll_offset + 1).min(max);
|
||||
}
|
||||
// Down arrow: scroll down by 1 (only when input is empty)
|
||||
KeyCode::Down if self.input.is_empty() => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
// Regular char: insert at cursor
|
||||
KeyCode::Char(c) => {
|
||||
self.input.insert(self.cursor_pos, c);
|
||||
self.cursor_pos += 1;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::tui::types::App;
|
||||
|
||||
/// Helper: create a key event with no modifiers.
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
/// Helper: create a key event with modifiers.
|
||||
fn key_mod(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent::new(code, modifiers)
|
||||
}
|
||||
|
||||
/// Helper: create a fresh App for testing.
|
||||
fn app() -> App {
|
||||
App::new("aabbcc".into(), None, "http://localhost:7700".into())
|
||||
}
|
||||
|
||||
/// Helper: type a string into the app one character at a time.
|
||||
fn type_str(app: &mut App, s: &str) {
|
||||
for c in s.chars() {
|
||||
app.handle_key_event(key(KeyCode::Char(c)));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text editing tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn char_insert() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
assert_eq!(app.input, "abc");
|
||||
assert_eq!(app.cursor_pos, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_deletes_char() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key(KeyCode::Backspace));
|
||||
assert_eq!(app.input, "ab");
|
||||
assert_eq!(app.cursor_pos, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_at_start_does_nothing() {
|
||||
let mut app = app();
|
||||
assert!(app.input.is_empty());
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
app.handle_key_event(key(KeyCode::Backspace));
|
||||
assert!(app.input.is_empty());
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_at_cursor() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key(KeyCode::Left));
|
||||
app.handle_key_event(key(KeyCode::Delete));
|
||||
assert_eq!(app.input, "ab");
|
||||
assert_eq!(app.cursor_pos, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_u_clears_line() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "hello");
|
||||
app.handle_key_event(key_mod(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||
assert!(app.input.is_empty());
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_k_kills_to_end() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "hello");
|
||||
app.handle_key_event(key(KeyCode::Home));
|
||||
app.handle_key_event(key(KeyCode::Right));
|
||||
app.handle_key_event(key(KeyCode::Right));
|
||||
app.handle_key_event(key_mod(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
||||
assert_eq!(app.input, "he");
|
||||
assert_eq!(app.cursor_pos, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_w_deletes_word() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "hello world");
|
||||
app.handle_key_event(key_mod(KeyCode::Char('w'), KeyModifiers::CONTROL));
|
||||
assert_eq!(app.input, "hello ");
|
||||
assert_eq!(app.cursor_pos, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_backspace_deletes_word() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "hello world");
|
||||
app.handle_key_event(key_mod(KeyCode::Backspace, KeyModifiers::ALT));
|
||||
assert_eq!(app.input, "hello ");
|
||||
assert_eq!(app.cursor_pos, 6);
|
||||
}
|
||||
|
||||
// ── Cursor movement tests ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn left_arrow_moves_cursor() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key(KeyCode::Left));
|
||||
assert_eq!(app.cursor_pos, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_arrow_moves_cursor() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key(KeyCode::Home));
|
||||
app.handle_key_event(key(KeyCode::Right));
|
||||
assert_eq!(app.cursor_pos, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_moves_to_start() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key(KeyCode::Home));
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_moves_to_end() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key(KeyCode::Home));
|
||||
app.handle_key_event(key(KeyCode::End));
|
||||
assert_eq!(app.cursor_pos, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_a_moves_to_start() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key_mod(KeyCode::Char('a'), KeyModifiers::CONTROL));
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_e_moves_to_end() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key(KeyCode::Home));
|
||||
app.handle_key_event(key_mod(KeyCode::Char('e'), KeyModifiers::CONTROL));
|
||||
assert_eq!(app.cursor_pos, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_at_start_does_nothing() {
|
||||
let mut app = app();
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
app.handle_key_event(key(KeyCode::Left));
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
}
|
||||
|
||||
// ── Quit tests ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_quits() {
|
||||
let mut app = app();
|
||||
app.handle_key_event(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
assert!(app.should_quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_quits() {
|
||||
let mut app = app();
|
||||
app.handle_key_event(key(KeyCode::Esc));
|
||||
assert!(app.should_quit);
|
||||
}
|
||||
|
||||
// ── Scroll tests ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn page_up_increases_scroll_offset() {
|
||||
let mut app = app();
|
||||
// App::new creates 3 system messages, so max = 3 - 1 = 2
|
||||
let msg_count = app.messages.lock().unwrap().len();
|
||||
app.handle_key_event(key(KeyCode::PageUp));
|
||||
// scroll_offset = min(10, msg_count - 1)
|
||||
let expected = 10usize.min(msg_count.saturating_sub(1));
|
||||
assert_eq!(app.scroll_offset, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_down_decreases_scroll_offset() {
|
||||
let mut app = app();
|
||||
app.scroll_offset = 15;
|
||||
app.handle_key_event(key(KeyCode::PageDown));
|
||||
assert_eq!(app.scroll_offset, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_down_clamps_to_zero() {
|
||||
let mut app = app();
|
||||
app.scroll_offset = 3;
|
||||
app.handle_key_event(key(KeyCode::PageDown));
|
||||
assert_eq!(app.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_arrow_scrolls_when_input_empty() {
|
||||
let mut app = app();
|
||||
assert!(app.input.is_empty());
|
||||
app.handle_key_event(key(KeyCode::Up));
|
||||
assert_eq!(app.scroll_offset, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_arrow_ignored_when_input_not_empty() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "hi");
|
||||
app.handle_key_event(key(KeyCode::Up));
|
||||
assert_eq!(app.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_arrow_scrolls_when_input_empty() {
|
||||
let mut app = app();
|
||||
app.scroll_offset = 5;
|
||||
assert!(app.input.is_empty());
|
||||
app.handle_key_event(key(KeyCode::Down));
|
||||
assert_eq!(app.scroll_offset, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_arrow_at_zero_stays_zero() {
|
||||
let mut app = app();
|
||||
assert!(app.input.is_empty());
|
||||
assert_eq!(app.scroll_offset, 0);
|
||||
app.handle_key_event(key(KeyCode::Down));
|
||||
assert_eq!(app.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_snaps_to_bottom_when_input_empty() {
|
||||
let mut app = app();
|
||||
app.scroll_offset = 10;
|
||||
assert!(app.input.is_empty());
|
||||
app.handle_key_event(key(KeyCode::End));
|
||||
assert_eq!(app.scroll_offset, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,72 @@
|
||||
pub mod app;
|
||||
mod types;
|
||||
mod draw;
|
||||
mod commands;
|
||||
mod file_transfer;
|
||||
mod input;
|
||||
mod network;
|
||||
|
||||
pub use app::run_tui;
|
||||
pub use types::App;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{self, Event, KeyCode};
|
||||
|
||||
use warzone_protocol::identity::{IdentityKeyPair, Seed};
|
||||
|
||||
use crate::net::ServerClient;
|
||||
use crate::storage::LocalDb;
|
||||
|
||||
/// Run the TUI event loop.
|
||||
pub async fn run_tui(
|
||||
our_fp: String,
|
||||
peer_fp: Option<String>,
|
||||
server_url: String,
|
||||
identity: IdentityKeyPair,
|
||||
poll_seed: Seed,
|
||||
db: LocalDb,
|
||||
) -> Result<()> {
|
||||
let mut terminal = ratatui::init();
|
||||
let client = ServerClient::new(&server_url);
|
||||
let db = Arc::new(db);
|
||||
|
||||
let mut app = App::new(our_fp.clone(), peer_fp, server_url);
|
||||
|
||||
// Derive a second identity for the poll loop (can't clone IdentityKeyPair)
|
||||
let poll_identity = poll_seed.derive_identity();
|
||||
let poll_messages = app.messages.clone();
|
||||
let poll_receipts = app.receipts.clone();
|
||||
let poll_pending_files = app.pending_files.clone();
|
||||
let poll_last_dm = app.last_dm_peer.clone();
|
||||
let poll_connected = app.connected.clone();
|
||||
let poll_client = client.clone();
|
||||
let poll_db = db.clone();
|
||||
let poll_fp = our_fp.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
network::poll_loop(poll_messages, poll_receipts, poll_pending_files, poll_fp, poll_identity, poll_db, poll_client, poll_last_dm, poll_connected).await;
|
||||
});
|
||||
|
||||
loop {
|
||||
terminal.draw(|frame| app.draw(frame))?;
|
||||
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.code == KeyCode::Enter {
|
||||
app.handle_send(&identity, &db, &client).await;
|
||||
app.scroll_offset = 0;
|
||||
} else {
|
||||
app.handle_key_event(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if app.should_quit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ratatui::restore();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
538
warzone/crates/warzone-client/src/tui/network.rs
Normal file
538
warzone/crates/warzone-client/src/tui/network.rs
Normal file
@@ -0,0 +1,538 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use sha2::{Sha256, Digest};
|
||||
use warzone_protocol::identity::IdentityKeyPair;
|
||||
use warzone_protocol::message::{ReceiptType, WireMessage};
|
||||
use warzone_protocol::ratchet::RatchetState;
|
||||
use warzone_protocol::types::Fingerprint;
|
||||
use warzone_protocol::x3dh;
|
||||
use x25519_dalek::PublicKey;
|
||||
|
||||
use crate::net::ServerClient;
|
||||
use crate::storage::LocalDb;
|
||||
|
||||
use chrono::Local;
|
||||
|
||||
use super::types::{ChatLine, PendingFileTransfer, ReceiptStatus, normfp};
|
||||
|
||||
/// Send a delivery receipt for a message back to its sender.
|
||||
fn send_receipt(
|
||||
our_fp: &str,
|
||||
sender_fp: &str,
|
||||
message_id: &str,
|
||||
receipt_type: ReceiptType,
|
||||
client: &ServerClient,
|
||||
) {
|
||||
let receipt = WireMessage::Receipt {
|
||||
sender_fingerprint: our_fp.to_string(),
|
||||
message_id: message_id.to_string(),
|
||||
receipt_type,
|
||||
};
|
||||
let encoded = match bincode::serialize(&receipt) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
let client = client.clone();
|
||||
let to = sender_fp.to_string();
|
||||
let from = our_fp.to_string();
|
||||
tokio::spawn(async move {
|
||||
let _ = client.send_message(&to, Some(&from), &encoded).await;
|
||||
});
|
||||
}
|
||||
|
||||
fn store_received(db: &LocalDb, sender_fp: &str, text: &str) {
|
||||
let _ = db.touch_contact(sender_fp, None);
|
||||
let _ = db.store_message(sender_fp, sender_fp, text, false);
|
||||
}
|
||||
|
||||
/// Process a single incoming raw message (shared by WS and HTTP paths).
|
||||
pub fn process_incoming(
|
||||
raw: &[u8],
|
||||
identity: &IdentityKeyPair,
|
||||
db: &LocalDb,
|
||||
messages: &Arc<Mutex<Vec<ChatLine>>>,
|
||||
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||
our_fp: &str,
|
||||
client: &ServerClient,
|
||||
last_dm_peer: &Arc<Mutex<Option<String>>>,
|
||||
) {
|
||||
match bincode::deserialize::<WireMessage>(raw) {
|
||||
Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client, last_dm_peer),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_wire_message(
|
||||
wire: WireMessage,
|
||||
identity: &IdentityKeyPair,
|
||||
db: &LocalDb,
|
||||
messages: &Arc<Mutex<Vec<ChatLine>>>,
|
||||
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||
our_fp: &str,
|
||||
client: &ServerClient,
|
||||
last_dm_peer: &Arc<Mutex<Option<String>>>,
|
||||
) {
|
||||
match wire {
|
||||
WireMessage::KeyExchange {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
sender_identity_encryption_key,
|
||||
ephemeral_public,
|
||||
used_one_time_pre_key_id,
|
||||
ratchet_message,
|
||||
} => {
|
||||
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
|
||||
Ok(fp) => fp,
|
||||
Err(_) => return,
|
||||
};
|
||||
let spk_secret = match db.load_signed_pre_key(1) {
|
||||
Ok(Some(s)) => s,
|
||||
_ => return,
|
||||
};
|
||||
let otpk_secret = if let Some(otpk_id) = used_one_time_pre_key_id {
|
||||
db.take_one_time_pre_key(otpk_id).ok().flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let their_id_x25519 = PublicKey::from(sender_identity_encryption_key);
|
||||
let their_eph = PublicKey::from(ephemeral_public);
|
||||
let shared_secret = match x3dh::respond(
|
||||
identity, &spk_secret, otpk_secret.as_ref(), &their_id_x25519, &their_eph,
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut state = RatchetState::init_bob(shared_secret, spk_secret);
|
||||
match state.decrypt(&ratchet_message) {
|
||||
Ok(plaintext) => {
|
||||
let text = String::from_utf8_lossy(&plaintext).to_string();
|
||||
let _ = db.save_session(&sender_fp, &state);
|
||||
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
|
||||
store_received(db, &sender_fingerprint, &text);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
|
||||
text,
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
|
||||
// Terminal bell for incoming DM
|
||||
print!("\x07");
|
||||
}
|
||||
Err(e) => {
|
||||
// Session auto-recovery: delete corrupted session, show warning
|
||||
let _ = db.delete_session(&sender_fp);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"[session reset] Decryption failed for {}. Session cleared — next message will re-establish.",
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)]
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
WireMessage::Message {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
ratchet_message,
|
||||
} => {
|
||||
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
|
||||
Ok(fp) => fp,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut state = match db.load_session(&sender_fp) {
|
||||
Ok(Some(s)) => s,
|
||||
_ => return,
|
||||
};
|
||||
match state.decrypt(&ratchet_message) {
|
||||
Ok(plaintext) => {
|
||||
let text = String::from_utf8_lossy(&plaintext).to_string();
|
||||
let _ = db.save_session(&sender_fp, &state);
|
||||
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
|
||||
store_received(db, &sender_fingerprint, &text);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
|
||||
text,
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
|
||||
// Terminal bell for incoming DM
|
||||
print!("\x07");
|
||||
}
|
||||
Err(e) => {
|
||||
// Session auto-recovery: delete corrupted session, show warning
|
||||
let _ = db.delete_session(&sender_fp);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"[session reset] Decryption failed for {}. Session cleared — next message will re-establish.",
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)]
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
WireMessage::Receipt {
|
||||
sender_fingerprint: _,
|
||||
message_id,
|
||||
receipt_type,
|
||||
} => {
|
||||
// Update receipt status for the referenced message
|
||||
let mut r = receipts.lock().unwrap();
|
||||
let current = r.get(&message_id);
|
||||
let should_update = match (&receipt_type, current) {
|
||||
(ReceiptType::Read, _) => true,
|
||||
(ReceiptType::Delivered, Some(ReceiptStatus::Sent)) => true,
|
||||
(ReceiptType::Delivered, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if should_update {
|
||||
let new_status = match receipt_type {
|
||||
ReceiptType::Delivered => ReceiptStatus::Delivered,
|
||||
ReceiptType::Read => ReceiptStatus::Read,
|
||||
};
|
||||
r.insert(message_id, new_status);
|
||||
}
|
||||
}
|
||||
WireMessage::FileHeader {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
filename,
|
||||
file_size,
|
||||
total_chunks,
|
||||
sha256,
|
||||
} => {
|
||||
let short_sender = &sender_fingerprint[..sender_fingerprint.len().min(12)];
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"Incoming file '{}' from {} ({} bytes, {} chunks)",
|
||||
filename, short_sender, file_size, total_chunks
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
|
||||
let transfer = PendingFileTransfer {
|
||||
filename,
|
||||
total_chunks,
|
||||
received: 0,
|
||||
chunks: vec![None; total_chunks as usize],
|
||||
sha256,
|
||||
file_size,
|
||||
};
|
||||
pending_files.lock().unwrap().insert(id, transfer);
|
||||
}
|
||||
WireMessage::FileChunk {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
filename: _,
|
||||
chunk_index,
|
||||
total_chunks: _,
|
||||
data,
|
||||
} => {
|
||||
// Decrypt the chunk data using our ratchet session with the sender
|
||||
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
|
||||
Ok(fp) => fp,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut state = match db.load_session(&sender_fp) {
|
||||
Ok(Some(s)) => s,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// The data field is a bincode-serialized RatchetMessage
|
||||
let ratchet_msg = match bincode::deserialize(&data) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let plaintext = match state.decrypt(&ratchet_msg) {
|
||||
Ok(pt) => {
|
||||
let _ = db.save_session(&sender_fp, &state);
|
||||
pt
|
||||
}
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut pf = pending_files.lock().unwrap();
|
||||
if let Some(transfer) = pf.get_mut(&id) {
|
||||
if (chunk_index as usize) < transfer.chunks.len() {
|
||||
if transfer.chunks[chunk_index as usize].is_none() {
|
||||
transfer.chunks[chunk_index as usize] = Some(plaintext);
|
||||
transfer.received += 1;
|
||||
}
|
||||
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"Receiving {} [{}/{}]...",
|
||||
transfer.filename, transfer.received, transfer.total_chunks
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
|
||||
// Check if all chunks received
|
||||
if transfer.received == transfer.total_chunks {
|
||||
let mut assembled = Vec::with_capacity(transfer.file_size as usize);
|
||||
for chunk in &transfer.chunks {
|
||||
if let Some(data) = chunk {
|
||||
assembled.extend_from_slice(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify SHA-256
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&assembled);
|
||||
let computed_hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
if computed_hash != transfer.sha256 {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"File '{}' integrity check FAILED (hash mismatch)",
|
||||
transfer.filename
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
} else {
|
||||
// Save to data_dir/downloads/
|
||||
let download_dir = crate::keystore::data_dir().join("downloads");
|
||||
let _ = std::fs::create_dir_all(&download_dir);
|
||||
let save_path = download_dir.join(&transfer.filename);
|
||||
match std::fs::write(&save_path, &assembled) {
|
||||
Ok(_) => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"File saved: {}",
|
||||
save_path.display()
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Failed to save file: {}", e),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove completed transfer
|
||||
pf.remove(&id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Received chunk without header — ignore
|
||||
}
|
||||
}
|
||||
WireMessage::GroupSenderKey {
|
||||
id: _,
|
||||
sender_fingerprint,
|
||||
group_name,
|
||||
generation,
|
||||
counter,
|
||||
ciphertext,
|
||||
} => {
|
||||
match db.load_sender_key(&sender_fingerprint, &group_name) {
|
||||
Ok(Some(mut sender_key)) => {
|
||||
let msg = warzone_protocol::sender_keys::SenderKeyMessage {
|
||||
sender_fingerprint: sender_fingerprint.clone(),
|
||||
group_name: group_name.clone(),
|
||||
generation,
|
||||
counter,
|
||||
ciphertext,
|
||||
};
|
||||
match sender_key.decrypt(&msg) {
|
||||
Ok(plaintext) => {
|
||||
let text = String::from_utf8_lossy(&plaintext).to_string();
|
||||
// Save updated sender key (counter advanced)
|
||||
let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key);
|
||||
store_received(db, &sender_fingerprint, &text);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: format!(
|
||||
"{} [#{}]",
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
group_name
|
||||
),
|
||||
text,
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"[group #{}] decrypt failed from {}: {}",
|
||||
group_name,
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
e
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"[group #{}] no sender key for {} — key distribution needed",
|
||||
group_name,
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)]
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
WireMessage::SenderKeyDistribution {
|
||||
sender_fingerprint,
|
||||
group_name,
|
||||
chain_key,
|
||||
generation,
|
||||
} => {
|
||||
let dist = warzone_protocol::sender_keys::SenderKeyDistribution {
|
||||
sender_fingerprint: sender_fingerprint.clone(),
|
||||
group_name: group_name.clone(),
|
||||
chain_key,
|
||||
generation,
|
||||
};
|
||||
let sender_key = dist.into_sender_key();
|
||||
let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"Received sender key from {} for #{}",
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
group_name
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
WireMessage::CallSignal {
|
||||
id: _,
|
||||
sender_fingerprint,
|
||||
signal_type,
|
||||
payload: _,
|
||||
target: _,
|
||||
} => {
|
||||
let type_str = format!("{:?}", signal_type);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
|
||||
text: format!("\u{1f4de} Call signal: {}", type_str),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Real-time message loop via WebSocket (falls back to HTTP polling).
|
||||
pub async fn poll_loop(
|
||||
messages: Arc<Mutex<Vec<ChatLine>>>,
|
||||
receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||
pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||
our_fp: String,
|
||||
identity: IdentityKeyPair,
|
||||
db: Arc<LocalDb>,
|
||||
client: ServerClient,
|
||||
last_dm_peer: Arc<Mutex<Option<String>>>,
|
||||
connected: Arc<AtomicBool>,
|
||||
) {
|
||||
let fp = normfp(&our_fp);
|
||||
|
||||
// Try WebSocket first
|
||||
let ws_url = client.base_url
|
||||
.replace("http://", "ws://")
|
||||
.replace("https://", "wss://");
|
||||
let ws_url = format!("{}/v1/ws/{}", ws_url, fp);
|
||||
|
||||
loop {
|
||||
match tokio_tungstenite::connect_async(&ws_url).await {
|
||||
Ok((ws_stream, _)) => {
|
||||
connected.store(true, Ordering::Relaxed);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Real-time connection established".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
|
||||
use futures_util::StreamExt;
|
||||
let (_, mut read) = ws_stream.split();
|
||||
|
||||
while let Some(Ok(msg)) = read.next().await {
|
||||
if let tokio_tungstenite::tungstenite::Message::Binary(data) = msg {
|
||||
process_incoming(&data, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, &last_dm_peer);
|
||||
}
|
||||
}
|
||||
|
||||
connected.store(false, Ordering::Relaxed);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Connection lost, reconnecting...".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
connected.store(false, Ordering::Relaxed);
|
||||
// Fallback to HTTP polling
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let raw_msgs = match client.poll_messages(&our_fp).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for raw in &raw_msgs {
|
||||
process_incoming(raw, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, &last_dm_peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
220
warzone/crates/warzone-client/src/tui/types.rs
Normal file
220
warzone/crates/warzone-client/src/tui/types.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
|
||||
/// Maximum file size: 10 MB.
|
||||
pub const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
||||
/// Chunk size: 64 KB.
|
||||
pub const CHUNK_SIZE: usize = 64 * 1024;
|
||||
|
||||
/// State for tracking an incoming chunked file transfer.
|
||||
#[derive(Clone)]
|
||||
pub struct PendingFileTransfer {
|
||||
pub filename: String,
|
||||
pub total_chunks: u32,
|
||||
pub received: u32,
|
||||
pub chunks: Vec<Option<Vec<u8>>>,
|
||||
pub sha256: String,
|
||||
pub file_size: u64,
|
||||
}
|
||||
|
||||
/// Receipt status for a sent message.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ReceiptStatus {
|
||||
Sent,
|
||||
Delivered,
|
||||
Read,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub input: String,
|
||||
pub messages: Arc<Mutex<Vec<ChatLine>>>,
|
||||
pub our_fp: String,
|
||||
pub peer_fp: Option<String>,
|
||||
pub server_url: String,
|
||||
pub should_quit: bool,
|
||||
pub cursor_pos: usize,
|
||||
pub last_dm_peer: Arc<Mutex<Option<String>>>,
|
||||
/// Track receipt status for messages we sent, keyed by message ID.
|
||||
pub receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||
/// Pending incoming file transfers, keyed by file ID.
|
||||
pub pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||
/// Scroll offset from bottom (0 = pinned to newest).
|
||||
pub scroll_offset: usize,
|
||||
/// Whether the WebSocket connection is active.
|
||||
pub connected: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ChatLine {
|
||||
pub sender: String,
|
||||
pub text: String,
|
||||
pub is_system: bool,
|
||||
pub is_self: bool,
|
||||
/// Message ID (for sent messages, used to track receipts).
|
||||
pub message_id: Option<String>,
|
||||
/// When this message was created/received.
|
||||
pub timestamp: DateTime<Local>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(our_fp: String, peer_fp: Option<String>, server_url: String) -> Self {
|
||||
let messages = Arc::new(Mutex::new(vec![ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("You are {}", our_fp),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
}]));
|
||||
|
||||
if let Some(ref peer) = peer_fp {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Chatting with {}", peer),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
} else {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "No peer set. Use /peer <fp>, /peer @alias, or /g <group>".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "/alias /peer /g /gleave /gkick /gmembers /file /info /quit".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
|
||||
App {
|
||||
input: String::new(),
|
||||
messages,
|
||||
our_fp,
|
||||
peer_fp,
|
||||
server_url,
|
||||
should_quit: false,
|
||||
last_dm_peer: Arc::new(Mutex::new(None)),
|
||||
cursor_pos: 0,
|
||||
receipts: Arc::new(Mutex::new(HashMap::new())),
|
||||
pending_files: Arc::new(Mutex::new(HashMap::new())),
|
||||
scroll_offset: 0,
|
||||
connected: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_message(&self, line: ChatLine) {
|
||||
self.messages.lock().unwrap().push(line);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normfp(fp: &str) -> String {
|
||||
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[test]
|
||||
fn app_new_initializes_scroll_offset_to_zero() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
assert_eq!(app.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_initializes_connected_to_false() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
assert!(!app.connected.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_creates_system_messages() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
let msgs = app.messages.lock().unwrap();
|
||||
assert!(msgs.len() >= 2);
|
||||
assert!(msgs[0].is_system);
|
||||
assert!(msgs[0].text.contains("aabbcc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_with_peer_shows_chatting_message() {
|
||||
let app = App::new("aabbcc".into(), Some("ddeeff".into()), "http://localhost:7700".into());
|
||||
let msgs = app.messages.lock().unwrap();
|
||||
let has_chatting = msgs.iter().any(|m| m.text.contains("Chatting with") && m.text.contains("ddeeff"));
|
||||
assert!(has_chatting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_without_peer_shows_no_peer_message() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
let msgs = app.messages.lock().unwrap();
|
||||
let has_no_peer = msgs.iter().any(|m| m.text.contains("No peer set"));
|
||||
assert!(has_no_peer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatline_has_timestamp() {
|
||||
let line = ChatLine {
|
||||
sender: "test".into(),
|
||||
text: "hello".into(),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
};
|
||||
// Timestamp should be within the last second
|
||||
let elapsed = Local::now().signed_duration_since(line.timestamp);
|
||||
assert!(elapsed.num_seconds() < 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_message_appends_to_list() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
let initial_count = app.messages.lock().unwrap().len();
|
||||
app.add_message(ChatLine {
|
||||
sender: "test".into(),
|
||||
text: "new message".into(),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
let new_count = app.messages.lock().unwrap().len();
|
||||
assert_eq!(new_count, initial_count + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normfp_strips_non_hex_and_lowercases() {
|
||||
assert_eq!(normfp("AA-BB-CC"), "aabbcc");
|
||||
assert_eq!(normfp("0x1234ABCD"), "01234abcd");
|
||||
assert_eq!(normfp("hello"), "e"); // only 'e' is hex
|
||||
assert_eq!(normfp("AABB"), "aabb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_cursor_pos_zero() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
assert!(app.input.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_should_quit_false() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
assert!(!app.should_quit);
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,5 @@ rand.workspace = true
|
||||
futures-util = "0.3"
|
||||
ed25519-dalek.workspace = true
|
||||
bincode.workspace = true
|
||||
sha2.workspace = true
|
||||
reqwest = { workspace = true, features = ["rustls-tls", "json"] }
|
||||
|
||||
84
warzone/crates/warzone-server/src/auth_middleware.rs
Normal file
84
warzone/crates/warzone-server/src/auth_middleware.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
//! Auth enforcement middleware: axum extractor that validates bearer tokens.
|
||||
//!
|
||||
//! Reads `Authorization: Bearer <token>` from request headers, validates via
|
||||
//! [`crate::routes::auth::validate_token`], and returns the authenticated
|
||||
//! fingerprint or a 401 rejection.
|
||||
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Extractor that validates a bearer token and provides the authenticated fingerprint.
|
||||
///
|
||||
/// Place this as the **first** parameter in any handler that requires authentication.
|
||||
/// The extractor will reject the request with 401 if the token is missing or invalid.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// async fn my_handler(
|
||||
/// auth: AuthFingerprint,
|
||||
/// State(state): State<AppState>,
|
||||
/// ) -> impl IntoResponse {
|
||||
/// let fp = auth.fingerprint; // guaranteed valid
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
pub struct AuthFingerprint {
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
#[axum::async_trait]
|
||||
impl FromRequestParts<AppState> for AuthFingerprint {
|
||||
type Rejection = AuthError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let header = parts
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
let token = match header {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return Err(AuthError::MissingToken),
|
||||
};
|
||||
|
||||
match crate::routes::auth::validate_token(&state.db.tokens, &token) {
|
||||
Some(fingerprint) => Ok(AuthFingerprint { fingerprint }),
|
||||
None => Err(AuthError::InvalidToken),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejection type for [`AuthFingerprint`] extractor failures.
|
||||
pub enum AuthError {
|
||||
/// No `Authorization: Bearer <token>` header was present (or it was empty).
|
||||
MissingToken,
|
||||
/// The token was present but did not pass validation (expired or unknown).
|
||||
InvalidToken,
|
||||
}
|
||||
|
||||
impl IntoResponse for AuthError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, msg) = match self {
|
||||
AuthError::MissingToken => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing or empty Authorization: Bearer <token> header",
|
||||
),
|
||||
AuthError::InvalidToken => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"invalid or expired token",
|
||||
),
|
||||
};
|
||||
(status, axum::Json(serde_json::json!({ "error": msg }))).into_response()
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ pub struct Database {
|
||||
pub groups: sled::Tree,
|
||||
pub aliases: sled::Tree,
|
||||
pub tokens: sled::Tree,
|
||||
pub calls: sled::Tree,
|
||||
pub missed_calls: sled::Tree,
|
||||
_db: sled::Db,
|
||||
}
|
||||
|
||||
@@ -17,12 +19,16 @@ impl Database {
|
||||
let groups = db.open_tree("groups")?;
|
||||
let aliases = db.open_tree("aliases")?;
|
||||
let tokens = db.open_tree("tokens")?;
|
||||
let calls = db.open_tree("calls")?;
|
||||
let missed_calls = db.open_tree("missed_calls")?;
|
||||
Ok(Database {
|
||||
keys,
|
||||
messages,
|
||||
groups,
|
||||
aliases,
|
||||
tokens,
|
||||
calls,
|
||||
missed_calls,
|
||||
_db: db,
|
||||
})
|
||||
}
|
||||
|
||||
212
warzone/crates/warzone-server/src/federation.rs
Normal file
212
warzone/crates/warzone-server/src/federation.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
//! Federation: two-server message relay with shared-secret authentication.
|
||||
//!
|
||||
//! Each server periodically announces its connected clients to the peer.
|
||||
//! When a message is destined for a remote client, it's forwarded via HTTP.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
/// Federation configuration loaded from JSON.
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct FederationConfig {
|
||||
pub server_id: String,
|
||||
pub shared_secret: String,
|
||||
pub peer: PeerConfig,
|
||||
#[serde(default = "default_interval")]
|
||||
pub presence_interval_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct PeerConfig {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
fn default_interval() -> u64 { 5 }
|
||||
|
||||
/// Load federation config from a JSON file. Returns None if path is empty.
|
||||
pub fn load_config(path: &str) -> anyhow::Result<FederationConfig> {
|
||||
let data = std::fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read federation config '{}': {}", path, e))?;
|
||||
let config: FederationConfig = serde_json::from_str(&data)
|
||||
.map_err(|e| anyhow::anyhow!("invalid federation config: {}", e))?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Remote presence: which fingerprints are on the peer server.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemotePresence {
|
||||
pub peer_url: String,
|
||||
pub peer_id: String,
|
||||
pub fingerprints: HashSet<String>,
|
||||
pub last_updated: i64,
|
||||
}
|
||||
|
||||
impl RemotePresence {
|
||||
pub fn new(peer_url: String, peer_id: String) -> Self {
|
||||
RemotePresence {
|
||||
peer_url,
|
||||
peer_id,
|
||||
fingerprints: HashSet::new(),
|
||||
last_updated: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a fingerprint is on the remote server.
|
||||
pub fn contains(&self, fp: &str) -> bool {
|
||||
self.fingerprints.contains(fp)
|
||||
}
|
||||
|
||||
/// Is the peer still alive? (heard from within 3 intervals)
|
||||
pub fn is_alive(&self, interval_secs: u64) -> bool {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
now - self.last_updated < (interval_secs as i64 * 3)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle for communicating with the federation peer.
|
||||
#[derive(Clone)]
|
||||
pub struct FederationHandle {
|
||||
pub config: FederationConfig,
|
||||
pub client: reqwest::Client,
|
||||
pub remote_presence: Arc<Mutex<RemotePresence>>,
|
||||
}
|
||||
|
||||
impl FederationHandle {
|
||||
pub fn new(config: FederationConfig) -> Self {
|
||||
let remote_presence = Arc::new(Mutex::new(RemotePresence::new(
|
||||
config.peer.url.clone(),
|
||||
config.peer.id.clone(),
|
||||
)));
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("failed to build HTTP client");
|
||||
FederationHandle { config, client, remote_presence }
|
||||
}
|
||||
|
||||
/// Check if a fingerprint is known to be on the peer server.
|
||||
pub async fn is_remote(&self, fp: &str) -> bool {
|
||||
let rp = self.remote_presence.lock().await;
|
||||
rp.is_alive(self.config.presence_interval_secs) && rp.contains(fp)
|
||||
}
|
||||
|
||||
/// Forward a message to the peer server for delivery.
|
||||
/// Returns true if the peer accepted it.
|
||||
pub async fn forward_message(&self, to_fp: &str, message: &[u8]) -> bool {
|
||||
let url = format!("{}/v1/federation/forward", self.config.peer.url);
|
||||
let body = serde_json::json!({
|
||||
"to": to_fp,
|
||||
"message": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, message),
|
||||
"from_server": self.config.server_id,
|
||||
});
|
||||
let body_str = serde_json::to_string(&body).unwrap_or_default();
|
||||
let token = compute_token(&self.config.shared_secret, body_str.as_bytes());
|
||||
|
||||
match self.client.post(&url)
|
||||
.header("X-Federation-Token", &token)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body_str)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
tracing::debug!("Federation: forwarded message to {} for {}", self.config.peer.id, to_fp);
|
||||
true
|
||||
}
|
||||
Ok(resp) => {
|
||||
tracing::warn!("Federation: peer {} rejected forward: {}", self.config.peer.id, resp.status());
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Federation: failed to forward to {}: {}", self.config.peer.id, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send our local presence to the peer.
|
||||
pub async fn announce_presence(&self, fingerprints: Vec<String>) -> bool {
|
||||
let url = format!("{}/v1/federation/presence", self.config.peer.url);
|
||||
let body = serde_json::json!({
|
||||
"server_id": self.config.server_id,
|
||||
"fingerprints": fingerprints,
|
||||
"timestamp": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let body_str = serde_json::to_string(&body).unwrap_or_default();
|
||||
let token = compute_token(&self.config.shared_secret, body_str.as_bytes());
|
||||
|
||||
match self.client.post(&url)
|
||||
.header("X-Federation-Token", &token)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body_str)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => true,
|
||||
Ok(resp) => {
|
||||
tracing::warn!("Federation: presence announce to {} failed: {}", self.config.peer.id, resp.status());
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Federation: presence announce to {} error: {}", self.config.peer.id, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task: periodically sync presence with peer.
|
||||
pub async fn presence_sync_loop(
|
||||
handle: FederationHandle,
|
||||
connections: crate::state::Connections,
|
||||
) {
|
||||
let interval = std::time::Duration::from_secs(handle.config.presence_interval_secs);
|
||||
tracing::info!(
|
||||
"Federation: presence sync started (peer={}, interval={}s)",
|
||||
handle.config.peer.id, handle.config.presence_interval_secs
|
||||
);
|
||||
|
||||
loop {
|
||||
// Collect local fingerprints
|
||||
let fps: Vec<String> = {
|
||||
let conns = connections.lock().await;
|
||||
conns.keys().cloned().collect()
|
||||
};
|
||||
|
||||
// Announce to peer
|
||||
let ok = handle.announce_presence(fps.clone()).await;
|
||||
if ok {
|
||||
tracing::debug!("Federation: announced {} fingerprints to {}", fps.len(), handle.config.peer.id);
|
||||
}
|
||||
|
||||
// Clear stale remote presence if peer hasn't responded
|
||||
{
|
||||
let mut rp = handle.remote_presence.lock().await;
|
||||
if !rp.is_alive(handle.config.presence_interval_secs) && !rp.fingerprints.is_empty() {
|
||||
tracing::warn!("Federation: peer {} stale — clearing remote presence ({} fps)",
|
||||
handle.config.peer.id, rp.fingerprints.len());
|
||||
rp.fingerprints.clear();
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute an auth token: SHA-256(secret || body). Simple HMAC-like construction.
|
||||
pub fn compute_token(secret: &str, body: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(secret.as_bytes());
|
||||
hasher.update(body);
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Verify an auth token.
|
||||
pub fn verify_token(secret: &str, body: &[u8], token: &str) -> bool {
|
||||
let expected = compute_token(secret, body);
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
expected.len() == token.len() && expected.as_bytes().iter().zip(token.as_bytes()).all(|(a, b)| a == b)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod auth_middleware;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod errors;
|
||||
pub mod federation;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use clap::Parser;
|
||||
|
||||
pub mod auth_middleware;
|
||||
mod config;
|
||||
mod db;
|
||||
mod errors;
|
||||
mod federation;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
@@ -16,6 +18,10 @@ struct Cli {
|
||||
/// Database directory
|
||||
#[arg(short, long, default_value = "./warzone-data")]
|
||||
data_dir: String,
|
||||
|
||||
/// Federation config file (JSON). Enables server-to-server message relay.
|
||||
#[arg(short, long)]
|
||||
federation: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -30,11 +36,38 @@ async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
tracing::info!("Warzone server starting on {}", cli.bind);
|
||||
|
||||
let state = state::AppState::new(&cli.data_dir)?;
|
||||
let mut state = state::AppState::new(&cli.data_dir)?;
|
||||
|
||||
// Load federation config if provided
|
||||
if let Some(ref fed_path) = cli.federation {
|
||||
let fed_config = federation::load_config(fed_path)?;
|
||||
tracing::info!(
|
||||
"Federation enabled: server_id={}, peer={}@{}",
|
||||
fed_config.server_id, fed_config.peer.id, fed_config.peer.url
|
||||
);
|
||||
let handle = federation::FederationHandle::new(fed_config);
|
||||
state.federation = Some(handle);
|
||||
}
|
||||
|
||||
// Spawn federation presence sync if enabled
|
||||
if let Some(ref federation) = state.federation {
|
||||
let handle = federation.clone();
|
||||
let connections = state.connections.clone();
|
||||
tokio::spawn(async move {
|
||||
federation::presence_sync_loop(handle, connections).await;
|
||||
});
|
||||
}
|
||||
|
||||
let cors = tower_http::cors::CorsLayer::new()
|
||||
.allow_origin(tower_http::cors::Any)
|
||||
.allow_methods(tower_http::cors::Any)
|
||||
.allow_headers(tower_http::cors::Any);
|
||||
|
||||
let app = axum::Router::new()
|
||||
.merge(routes::web_router())
|
||||
.nest("/v1", routes::router())
|
||||
.layer(cors)
|
||||
.layer(tower::limit::ConcurrencyLimitLayer::new(200))
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ struct RegisterRequest {
|
||||
/// - Expired aliases (past grace period) can be reclaimed by anyone
|
||||
/// - Expired aliases (within grace period) can only be reclaimed by recovery key
|
||||
async fn register_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -190,6 +191,7 @@ struct RecoverRequest {
|
||||
|
||||
/// Recover an alias using the recovery key. Works even if expired (within or past grace).
|
||||
async fn recover_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RecoverRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -244,6 +246,7 @@ struct RenewRequest {
|
||||
|
||||
/// Renew/heartbeat — resets the TTL. Called automatically on activity.
|
||||
async fn renew_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RenewRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -347,6 +350,7 @@ struct UnregisterRequest {
|
||||
|
||||
/// Remove your own alias.
|
||||
async fn unregister_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<UnregisterRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -381,6 +385,7 @@ struct AdminRemoveRequest {
|
||||
|
||||
/// Admin: remove any alias.
|
||||
async fn admin_remove_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<AdminRemoveRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
|
||||
233
warzone/crates/warzone-server/src/routes/calls.rs
Normal file
233
warzone/crates/warzone-server/src/routes/calls.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::{AppState, CallState, CallStatus};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/calls/initiate", post(initiate_call))
|
||||
.route("/calls/:id", get(get_call))
|
||||
.route("/calls/:id/end", post(end_call))
|
||||
.route("/calls/active", get(active_calls))
|
||||
.route("/calls/missed", post(get_missed_calls))
|
||||
.route("/groups/:name/call", post(initiate_group_call))
|
||||
}
|
||||
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InitiateRequest {
|
||||
caller: String,
|
||||
callee: String,
|
||||
}
|
||||
|
||||
async fn initiate_call(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<InitiateRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let call_id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let call = CallState {
|
||||
call_id: call_id.clone(),
|
||||
caller_fp: normalize_fp(&req.caller),
|
||||
callee_fp: normalize_fp(&req.callee),
|
||||
group_name: None,
|
||||
room_id: None,
|
||||
status: CallStatus::Ringing,
|
||||
created_at: now,
|
||||
answered_at: None,
|
||||
ended_at: None,
|
||||
};
|
||||
state.active_calls.lock().await.insert(call_id.clone(), call.clone());
|
||||
state.db.calls.insert(call_id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
|
||||
tracing::info!("Call initiated: {} -> {}", call.caller_fp, call.callee_fp);
|
||||
Ok(Json(serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"status": "ringing",
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_call(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
// Try in-memory first, then DB
|
||||
if let Some(call) = state.active_calls.lock().await.get(&id) {
|
||||
return Ok(Json(serde_json::to_value(call)?));
|
||||
}
|
||||
if let Some(data) = state.db.calls.get(id.as_bytes())? {
|
||||
let call: CallState = serde_json::from_slice(&data)?;
|
||||
return Ok(Json(serde_json::to_value(&call)?));
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "error": "call not found" })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EndCallRequest {
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
async fn end_call(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<EndCallRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let _fp = normalize_fp(&req.fingerprint);
|
||||
let mut calls = state.active_calls.lock().await;
|
||||
if let Some(mut call) = calls.remove(&id) {
|
||||
call.status = CallStatus::Ended;
|
||||
call.ended_at = Some(now);
|
||||
state.db.calls.insert(id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
|
||||
return Ok(Json(serde_json::json!({ "ok": true, "call_id": id })));
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "error": "call not found or already ended" })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ActiveQuery {
|
||||
fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
async fn active_calls(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ActiveQuery>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let calls = state.active_calls.lock().await;
|
||||
let filtered: Vec<&CallState> = match q.fingerprint {
|
||||
Some(ref fp) => {
|
||||
let fp = normalize_fp(fp);
|
||||
calls.values().filter(|c| c.caller_fp == fp || c.callee_fp == fp).collect()
|
||||
}
|
||||
None => calls.values().collect(),
|
||||
};
|
||||
Ok(Json(serde_json::json!({ "calls": filtered })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MissedRequest {
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
async fn get_missed_calls(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<MissedRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let fp = normalize_fp(&req.fingerprint);
|
||||
let prefix = format!("missed:{}", fp);
|
||||
let mut missed = Vec::new();
|
||||
let mut keys = Vec::new();
|
||||
for (key, value) in state.db.missed_calls.scan_prefix(prefix.as_bytes()).flatten() {
|
||||
if let Ok(record) = serde_json::from_slice::<serde_json::Value>(&value) {
|
||||
missed.push(record);
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
// Delete after reading
|
||||
for key in &keys {
|
||||
let _ = state.db.missed_calls.remove(key);
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "missed_calls": missed })))
|
||||
}
|
||||
|
||||
// --- FC-5: Group call ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GroupCallRequest {
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
/// Deterministic room ID from group name: hex(SHA-256("featherchat-group:" + name)[:16])
|
||||
fn hash_room_name(group_name: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(format!("featherchat-group:{}", group_name).as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
hex::encode(&hash[..16])
|
||||
}
|
||||
|
||||
async fn initiate_group_call(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<GroupCallRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let caller_fp = normalize_fp(&req.fingerprint);
|
||||
|
||||
// Load group
|
||||
let group_data = match state.db.groups.get(name.as_bytes())? {
|
||||
Some(d) => d,
|
||||
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
|
||||
};
|
||||
let group: serde_json::Value = serde_json::from_slice(&group_data)?;
|
||||
let members: Vec<String> = group.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();
|
||||
|
||||
// Verify caller is a member
|
||||
if !members.contains(&caller_fp) {
|
||||
return Ok(Json(serde_json::json!({ "error": "not a member of this group" })));
|
||||
}
|
||||
|
||||
let room_id = hash_room_name(&name);
|
||||
let call_id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// Create call state
|
||||
let call = CallState {
|
||||
call_id: call_id.clone(),
|
||||
caller_fp: caller_fp.clone(),
|
||||
callee_fp: "group".to_string(),
|
||||
group_name: Some(name.clone()),
|
||||
room_id: Some(room_id.clone()),
|
||||
status: CallStatus::Ringing,
|
||||
created_at: now,
|
||||
answered_at: None,
|
||||
ended_at: None,
|
||||
};
|
||||
state.active_calls.lock().await.insert(call_id.clone(), call.clone());
|
||||
state.db.calls.insert(call_id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
|
||||
|
||||
// Fan out CallSignal::Offer to all online members (except caller)
|
||||
let offer = warzone_protocol::message::WireMessage::CallSignal {
|
||||
id: call_id.clone(),
|
||||
sender_fingerprint: caller_fp.clone(),
|
||||
signal_type: warzone_protocol::message::CallSignalType::Offer,
|
||||
payload: serde_json::json!({ "room_id": room_id, "group": name }).to_string(),
|
||||
target: format!("#{}", name),
|
||||
};
|
||||
let encoded = bincode::serialize(&offer)?;
|
||||
|
||||
let mut delivered = 0;
|
||||
for member in &members {
|
||||
if *member == caller_fp { continue; }
|
||||
if state.push_to_client(member, &encoded).await {
|
||||
delivered += 1;
|
||||
} else {
|
||||
// Queue for offline members
|
||||
let key = format!("queue:{}:{}", member, uuid::Uuid::new_v4());
|
||||
state.db.messages.insert(key.as_bytes(), encoded.as_slice())?;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Group call #{}: room={}, caller={}, notified={}/{}",
|
||||
name, room_id, caller_fp, delivered, members.len() - 1);
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"room_id": room_id,
|
||||
"group": name,
|
||||
"members_notified": delivered,
|
||||
"members_total": members.len() - 1,
|
||||
})))
|
||||
}
|
||||
102
warzone/crates/warzone-server/src/routes/devices.rs
Normal file
102
warzone/crates/warzone-server/src/routes/devices.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use crate::auth_middleware::AuthFingerprint;
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/devices", get(list_devices))
|
||||
.route("/devices/:id/kick", post(kick_device))
|
||||
.route("/devices/revoke-all", post(revoke_all))
|
||||
}
|
||||
|
||||
/// List active WS connections for the authenticated user.
|
||||
async fn list_devices(
|
||||
auth: AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let devices = state.list_devices(&auth.fingerprint).await;
|
||||
let list: Vec<serde_json::Value> = devices
|
||||
.iter()
|
||||
.map(|(id, connected_at)| {
|
||||
serde_json::json!({
|
||||
"device_id": id,
|
||||
"connected_at": connected_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let count = list.len();
|
||||
Ok(Json(serde_json::json!({
|
||||
"fingerprint": auth.fingerprint,
|
||||
"devices": list,
|
||||
"count": count,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Kick a specific device by ID. Requires auth -- only the device owner can kick.
|
||||
async fn kick_device(
|
||||
auth: AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path(device_id): axum::extract::Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let kicked = state.kick_device(&auth.fingerprint, &device_id).await;
|
||||
if kicked {
|
||||
tracing::info!("Device {} kicked by {}", device_id, auth.fingerprint);
|
||||
Ok(Json(serde_json::json!({ "ok": true, "kicked": device_id })))
|
||||
} else {
|
||||
Ok(Json(serde_json::json!({ "error": "device not found" })))
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke all sessions except the current one. Panic button.
|
||||
async fn revoke_all(
|
||||
auth: AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let keep_device = req
|
||||
.get("keep_device_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let removed = state
|
||||
.revoke_all_except(&auth.fingerprint, keep_device)
|
||||
.await;
|
||||
|
||||
// Also clear all tokens for this fingerprint except the current one
|
||||
// Scan tokens tree for this fingerprint
|
||||
let mut tokens_to_remove = Vec::new();
|
||||
for item in state.db.tokens.iter().flatten() {
|
||||
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&item.1) {
|
||||
if val.get("fingerprint").and_then(|v| v.as_str()) == Some(&auth.fingerprint) {
|
||||
tokens_to_remove.push(item.0.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only remove tokens if we actually revoked devices
|
||||
let tokens_cleared = if removed > 0 {
|
||||
let count = tokens_to_remove.len();
|
||||
for key in &tokens_to_remove {
|
||||
let _ = state.db.tokens.remove(key);
|
||||
}
|
||||
count
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"Revoke-all for {}: {} devices removed, {} tokens cleared",
|
||||
auth.fingerprint,
|
||||
removed,
|
||||
tokens_cleared,
|
||||
);
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"devices_removed": removed,
|
||||
"tokens_cleared": tokens_cleared,
|
||||
})))
|
||||
}
|
||||
144
warzone/crates/warzone-server/src/routes/federation.rs
Normal file
144
warzone/crates/warzone-server/src/routes/federation.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
//! Federation route handlers: receive presence updates and forwarded messages from peer server.
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/federation/presence", post(receive_presence))
|
||||
.route("/federation/forward", post(receive_forward))
|
||||
.route("/federation/status", axum::routing::get(federation_status))
|
||||
}
|
||||
|
||||
/// Extract and validate the federation token from headers.
|
||||
fn validate_request(state: &AppState, headers: &HeaderMap, body: &[u8]) -> Result<(), (StatusCode, String)> {
|
||||
let federation = state.federation.as_ref()
|
||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "federation not configured".to_string()))?;
|
||||
|
||||
let token = headers.get("x-federation-token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "missing X-Federation-Token header".to_string()))?;
|
||||
|
||||
if !crate::federation::verify_token(&federation.config.shared_secret, body, token) {
|
||||
return Err((StatusCode::UNAUTHORIZED, "invalid federation token".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive presence announcement from peer.
|
||||
/// POST /v1/federation/presence
|
||||
/// Body: { "server_id": "...", "fingerprints": [...], "timestamp": ... }
|
||||
async fn receive_presence(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> impl IntoResponse {
|
||||
if let Err((status, msg)) = validate_request(&state, &headers, &body) {
|
||||
return (status, Json(serde_json::json!({ "error": msg }))).into_response();
|
||||
}
|
||||
|
||||
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("invalid JSON: {}", e) }))).into_response(),
|
||||
};
|
||||
|
||||
let fingerprints: Vec<String> = parsed.get("fingerprints")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let server_id = parsed.get("server_id").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
|
||||
if let Some(ref federation) = state.federation {
|
||||
let mut rp = federation.remote_presence.lock().await;
|
||||
let count = fingerprints.len();
|
||||
rp.fingerprints = fingerprints.into_iter().collect();
|
||||
rp.last_updated = chrono::Utc::now().timestamp();
|
||||
tracing::debug!("Federation: received {} fingerprints from {}", count, server_id);
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response()
|
||||
}
|
||||
|
||||
/// Receive a forwarded message from peer.
|
||||
/// POST /v1/federation/forward
|
||||
/// Body: { "to": "fingerprint", "message": "base64...", "from_server": "..." }
|
||||
async fn receive_forward(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> impl IntoResponse {
|
||||
if let Err((status, msg)) = validate_request(&state, &headers, &body) {
|
||||
return (status, Json(serde_json::json!({ "error": msg }))).into_response();
|
||||
}
|
||||
|
||||
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("invalid JSON: {}", e) }))).into_response(),
|
||||
};
|
||||
|
||||
let to = match parsed.get("to").and_then(|v| v.as_str()) {
|
||||
Some(fp) => fp.to_string(),
|
||||
None => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "missing 'to' field" }))).into_response(),
|
||||
};
|
||||
|
||||
let message_b64 = match parsed.get("message").and_then(|v| v.as_str()) {
|
||||
Some(m) => m.to_string(),
|
||||
None => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "missing 'message' field" }))).into_response(),
|
||||
};
|
||||
|
||||
let message = match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &message_b64) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("invalid base64: {}", e) }))).into_response(),
|
||||
};
|
||||
|
||||
let from_server = parsed.get("from_server").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
|
||||
// Try to deliver locally
|
||||
let delivered = state.push_to_client(&to, &message).await;
|
||||
if !delivered {
|
||||
// Queue for later pickup
|
||||
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
|
||||
let _ = state.db.messages.insert(key.as_bytes(), message.as_slice());
|
||||
tracing::info!("Federation: queued forwarded message from {} for offline user {}", from_server, to);
|
||||
} else {
|
||||
tracing::info!("Federation: delivered forwarded message from {} to {}", from_server, to);
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(serde_json::json!({ "ok": true, "delivered": delivered }))).into_response()
|
||||
}
|
||||
|
||||
/// Federation health status.
|
||||
/// GET /v1/federation/status
|
||||
async fn federation_status(
|
||||
State(state): State<AppState>,
|
||||
) -> Json<serde_json::Value> {
|
||||
match state.federation {
|
||||
Some(ref federation) => {
|
||||
let rp = federation.remote_presence.lock().await;
|
||||
Json(serde_json::json!({
|
||||
"enabled": true,
|
||||
"server_id": federation.config.server_id,
|
||||
"peer_id": federation.config.peer.id,
|
||||
"peer_url": federation.config.peer.url,
|
||||
"peer_alive": rp.is_alive(federation.config.presence_interval_secs),
|
||||
"remote_clients": rp.fingerprints.len(),
|
||||
"last_sync": rp.last_updated,
|
||||
}))
|
||||
}
|
||||
None => {
|
||||
Json(serde_json::json!({
|
||||
"enabled": false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ fn save_group(db: &sled::Tree, group: &GroupInfo) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
async fn create_group(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -99,6 +100,7 @@ async fn create_group(
|
||||
}
|
||||
|
||||
async fn join_group(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<JoinRequest>,
|
||||
@@ -169,6 +171,7 @@ async fn list_groups(
|
||||
/// queue infrastructure — group messages look like 1:1 messages to the
|
||||
/// recipient, but with a group tag.
|
||||
async fn send_to_group(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<GroupSendRequest>,
|
||||
@@ -210,6 +213,7 @@ async fn send_to_group(
|
||||
}
|
||||
|
||||
async fn leave_group(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<JoinRequest>,
|
||||
@@ -235,6 +239,7 @@ struct KickRequest {
|
||||
}
|
||||
|
||||
async fn kick_member(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<KickRequest>,
|
||||
|
||||
@@ -54,6 +54,7 @@ struct RegisterResponse {
|
||||
}
|
||||
|
||||
async fn register_keys(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Json<RegisterResponse> {
|
||||
@@ -129,6 +130,7 @@ struct OtpkEntry {
|
||||
|
||||
/// Upload additional one-time pre-keys.
|
||||
async fn replenish_otpks(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ReplenishRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
|
||||
@@ -71,6 +71,7 @@ fn normalize_fp(fp: &str) -> String {
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SendRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -84,14 +85,11 @@ async fn send_message(
|
||||
}
|
||||
}
|
||||
|
||||
// Try WebSocket push first (instant delivery)
|
||||
if state.push_to_client(&to, &req.message).await {
|
||||
tracing::info!("Pushed message to {} via WS ({} bytes)", to, req.message.len());
|
||||
let delivered = state.deliver_or_queue(&to, &req.message).await;
|
||||
if delivered {
|
||||
tracing::info!("Delivered message to {} ({} bytes)", to, req.message.len());
|
||||
} else {
|
||||
// Queue in DB (offline delivery)
|
||||
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
|
||||
tracing::info!("Queuing message for {} ({} bytes)", to, req.message.len());
|
||||
state.db.messages.insert(key.as_bytes(), req.message)?;
|
||||
tracing::info!("Queued message for {} ({} bytes)", to, req.message.len());
|
||||
}
|
||||
|
||||
// Renew sender's alias TTL (sending = authenticated action)
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
mod aliases;
|
||||
pub mod auth;
|
||||
mod calls;
|
||||
mod devices;
|
||||
mod federation;
|
||||
mod groups;
|
||||
mod health;
|
||||
mod keys;
|
||||
pub mod messages;
|
||||
mod presence;
|
||||
mod web;
|
||||
mod ws;
|
||||
mod wzp;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
@@ -20,6 +25,11 @@ pub fn router() -> Router<AppState> {
|
||||
.merge(aliases::routes())
|
||||
.merge(auth::routes())
|
||||
.merge(ws::routes())
|
||||
.merge(calls::routes())
|
||||
.merge(devices::routes())
|
||||
.merge(presence::routes())
|
||||
.merge(wzp::routes())
|
||||
.merge(federation::routes())
|
||||
}
|
||||
|
||||
/// Web UI router (served at root, outside /v1)
|
||||
|
||||
57
warzone/crates/warzone-server/src/routes/presence.rs
Normal file
57
warzone/crates/warzone-server/src/routes/presence.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
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<AppState> {
|
||||
Router::new()
|
||||
.route("/presence/:fingerprint", get(get_presence))
|
||||
.route("/presence/batch", post(batch_presence))
|
||||
}
|
||||
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||
}
|
||||
|
||||
async fn get_presence(
|
||||
State(state): State<AppState>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let fp = normalize_fp(&fingerprint);
|
||||
let online = state.is_online(&fp).await;
|
||||
let devices = state.device_count(&fp).await;
|
||||
Ok(Json(serde_json::json!({
|
||||
"fingerprint": fp,
|
||||
"online": online,
|
||||
"devices": devices,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BatchRequest {
|
||||
fingerprints: Vec<String>,
|
||||
}
|
||||
|
||||
async fn batch_presence(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<BatchRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let mut results = Vec::new();
|
||||
for fp in &req.fingerprints {
|
||||
let fp = normalize_fp(fp);
|
||||
let online = state.is_online(&fp).await;
|
||||
let devices = state.device_count(&fp).await;
|
||||
results.push(serde_json::json!({
|
||||
"fingerprint": fp,
|
||||
"online": online,
|
||||
"devices": devices,
|
||||
}));
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "results": results })))
|
||||
}
|
||||
@@ -66,16 +66,20 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
// Register for push delivery
|
||||
let mut push_rx = state.register_ws(&fingerprint).await;
|
||||
let (_device_id, mut push_rx) = match state.register_ws(&fingerprint, None).await {
|
||||
Some(pair) => pair,
|
||||
None => {
|
||||
tracing::warn!("WS {}: rejected — connection limit reached", fingerprint);
|
||||
return; // closes the socket
|
||||
}
|
||||
};
|
||||
|
||||
// Send any queued messages from DB
|
||||
let prefix = format!("queue:{}", fingerprint);
|
||||
let mut keys_to_delete = Vec::new();
|
||||
for item in state.db.messages.scan_prefix(prefix.as_bytes()) {
|
||||
if let Ok((key, value)) = item {
|
||||
if ws_tx.send(Message::Binary(value.to_vec().into())).await.is_ok() {
|
||||
keys_to_delete.push(key);
|
||||
}
|
||||
for (key, value) in state.db.messages.scan_prefix(prefix.as_bytes()).flatten() {
|
||||
if ws_tx.send(Message::Binary(value.to_vec())).await.is_ok() {
|
||||
keys_to_delete.push(key);
|
||||
}
|
||||
}
|
||||
for key in &keys_to_delete {
|
||||
@@ -85,11 +89,34 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
tracing::info!("WS {}: flushed {} queued messages", fingerprint, keys_to_delete.len());
|
||||
}
|
||||
|
||||
// Flush missed calls (FC-7)
|
||||
let missed_prefix = format!("missed:{}", fingerprint);
|
||||
let mut missed_keys = Vec::new();
|
||||
for (key, value) in state.db.missed_calls.scan_prefix(missed_prefix.as_bytes()).flatten() {
|
||||
if let Ok(missed) = serde_json::from_slice::<serde_json::Value>(&value) {
|
||||
let wrapper = serde_json::json!({
|
||||
"type": "missed_call",
|
||||
"data": missed,
|
||||
});
|
||||
if let Ok(json_str) = serde_json::to_string(&wrapper) {
|
||||
if ws_tx.send(Message::Text(json_str)).await.is_ok() {
|
||||
missed_keys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in &missed_keys {
|
||||
let _ = state.db.missed_calls.remove(key);
|
||||
}
|
||||
if !missed_keys.is_empty() {
|
||||
tracing::info!("WS {}: flushed {} missed call notifications", fingerprint, missed_keys.len());
|
||||
}
|
||||
|
||||
// Spawn task to forward push messages to WS
|
||||
let _fp_clone = fingerprint.clone();
|
||||
let mut push_task = tokio::spawn(async move {
|
||||
while let Some(msg) = push_rx.recv().await {
|
||||
if ws_tx.send(Message::Binary(msg.into())).await.is_err() {
|
||||
if ws_tx.send(Message::Binary(msg)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -119,13 +146,77 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
}
|
||||
}
|
||||
|
||||
// Try push to connected client first
|
||||
if !state_clone.push_to_client(&to_fp, message).await {
|
||||
// Queue in DB
|
||||
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
||||
let _ = state_clone.db.messages.insert(key.as_bytes(), message);
|
||||
// Call signal side effects
|
||||
if let Ok(WireMessage::CallSignal { ref id, ref sender_fingerprint, ref signal_type, .. }) = bincode::deserialize::<WireMessage>(message) {
|
||||
use warzone_protocol::message::CallSignalType;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
match signal_type {
|
||||
CallSignalType::Offer => {
|
||||
let call = crate::state::CallState {
|
||||
call_id: id.clone(),
|
||||
caller_fp: sender_fingerprint.clone(),
|
||||
callee_fp: to_fp.clone(),
|
||||
group_name: None,
|
||||
room_id: None,
|
||||
status: crate::state::CallStatus::Ringing,
|
||||
created_at: now,
|
||||
answered_at: None,
|
||||
ended_at: None,
|
||||
};
|
||||
state_clone.active_calls.lock().await.insert(id.clone(), call.clone());
|
||||
// Persist to DB
|
||||
let _ = state_clone.db.calls.insert(
|
||||
id.as_bytes(),
|
||||
serde_json::to_vec(&call).unwrap_or_default(),
|
||||
);
|
||||
tracing::info!("Call {} started: {} -> {}", id, sender_fingerprint, to_fp);
|
||||
|
||||
// If callee is offline, record missed call (FC-7)
|
||||
if !state_clone.is_online(&to_fp).await {
|
||||
let missed_key = format!("missed:{}:{}", to_fp, id);
|
||||
let missed = serde_json::json!({
|
||||
"call_id": id,
|
||||
"caller_fp": sender_fingerprint,
|
||||
"timestamp": now,
|
||||
});
|
||||
let _ = state_clone.db.missed_calls.insert(
|
||||
missed_key.as_bytes(),
|
||||
serde_json::to_vec(&missed).unwrap_or_default(),
|
||||
);
|
||||
tracing::info!("Missed call recorded for offline user {}", to_fp);
|
||||
}
|
||||
}
|
||||
CallSignalType::Answer => {
|
||||
let mut calls = state_clone.active_calls.lock().await;
|
||||
if let Some(call) = calls.get_mut(id) {
|
||||
call.status = crate::state::CallStatus::Active;
|
||||
call.answered_at = Some(now);
|
||||
let _ = state_clone.db.calls.insert(
|
||||
id.as_bytes(),
|
||||
serde_json::to_vec(&call).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
tracing::info!("Call {} answered", id);
|
||||
}
|
||||
CallSignalType::Hangup | CallSignalType::Reject => {
|
||||
let mut calls = state_clone.active_calls.lock().await;
|
||||
if let Some(mut call) = calls.remove(id) {
|
||||
call.status = crate::state::CallStatus::Ended;
|
||||
call.ended_at = Some(now);
|
||||
let _ = state_clone.db.calls.insert(
|
||||
id.as_bytes(),
|
||||
serde_json::to_vec(&call).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
tracing::info!("Call {} ended", id);
|
||||
}
|
||||
_ => {} // Ringing, Busy, IceCandidate — route opaquely
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver via local WS, federation, or queue in DB
|
||||
state_clone.deliver_or_queue(&to_fp, message).await;
|
||||
|
||||
tracing::debug!("WS {}: routed message to {}", fp_clone2, to_fp);
|
||||
}
|
||||
}
|
||||
@@ -147,10 +238,8 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
}
|
||||
}
|
||||
|
||||
if !state_clone.push_to_client(&to_fp, &message).await {
|
||||
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
||||
let _ = state_clone.db.messages.insert(key.as_bytes(), message);
|
||||
}
|
||||
// Deliver via local WS, federation, or queue in DB
|
||||
state_clone.deliver_or_queue(&to_fp, &message).await;
|
||||
|
||||
// Renew alias TTL
|
||||
crate::routes::messages::renew_alias_ttl(
|
||||
@@ -181,9 +270,9 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
// We can't easily get the sender ref here, so just clean up by fingerprint
|
||||
// In production, use a unique connection ID
|
||||
let mut conns = state.connections.lock().await;
|
||||
if let Some(senders) = conns.get_mut(&fingerprint) {
|
||||
senders.retain(|s| !s.is_closed());
|
||||
if senders.is_empty() {
|
||||
if let Some(devices) = conns.get_mut(&fingerprint) {
|
||||
devices.retain(|d| !d.sender.is_closed());
|
||||
if devices.is_empty() {
|
||||
conns.remove(&fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
45
warzone/crates/warzone-server/src/routes/wzp.rs
Normal file
45
warzone/crates/warzone-server/src/routes/wzp.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/wzp/relay-config", get(relay_config))
|
||||
}
|
||||
|
||||
/// Returns the WZP relay address and a short-lived service token.
|
||||
///
|
||||
/// The web client calls this to discover where to connect for voice/video
|
||||
/// and gets a token to present to the relay for authentication.
|
||||
async fn relay_config(
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
// Issue a short-lived service token (5 minutes) for WZP relay auth.
|
||||
let token = hex::encode(rand::random::<[u8; 32]>());
|
||||
let expires = chrono::Utc::now().timestamp() + 300; // 5 minutes
|
||||
|
||||
state.db.tokens.insert(
|
||||
token.as_bytes(),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"fingerprint": "service:wzp",
|
||||
"service": "wzp",
|
||||
"expires_at": expires,
|
||||
}))?.as_slice(),
|
||||
)?;
|
||||
|
||||
// The relay address is configured server-side. For now, return a
|
||||
// placeholder that the admin sets via environment variable.
|
||||
let relay_addr = std::env::var("WZP_RELAY_ADDR")
|
||||
.unwrap_or_else(|_| "127.0.0.1:4433".to_string());
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"relay_addr": relay_addr,
|
||||
"token": token,
|
||||
"expires_in": 300,
|
||||
})))
|
||||
}
|
||||
@@ -4,14 +4,26 @@ use tokio::sync::{Mutex, mpsc};
|
||||
|
||||
use crate::db::Database;
|
||||
|
||||
/// Maximum WebSocket connections per fingerprint (multi-device cap).
|
||||
const MAX_WS_PER_FINGERPRINT: usize = 5;
|
||||
|
||||
/// Maximum number of message IDs to track for deduplication.
|
||||
const DEDUP_CAPACITY: usize = 10_000;
|
||||
|
||||
/// Per-connection sender: messages are pushed here for instant delivery.
|
||||
pub type WsSender = mpsc::UnboundedSender<Vec<u8>>;
|
||||
|
||||
/// Connected clients: fingerprint → list of WS senders (multiple devices).
|
||||
pub type Connections = Arc<Mutex<HashMap<String, Vec<WsSender>>>>;
|
||||
/// Metadata for a single connected device.
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceConnection {
|
||||
pub device_id: String,
|
||||
pub sender: WsSender,
|
||||
pub connected_at: i64,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
/// Connected clients: fingerprint → list of device connections (multiple devices).
|
||||
pub type Connections = Arc<Mutex<HashMap<String, Vec<DeviceConnection>>>>;
|
||||
|
||||
/// Bounded dedup tracker: FIFO eviction when capacity is exceeded.
|
||||
#[derive(Clone)]
|
||||
@@ -47,11 +59,35 @@ impl DedupTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Call lifecycle status.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum CallStatus {
|
||||
Ringing,
|
||||
Active,
|
||||
Ended,
|
||||
}
|
||||
|
||||
/// Server-side state for an active or recently ended call.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CallState {
|
||||
pub call_id: String,
|
||||
pub caller_fp: String,
|
||||
pub callee_fp: String,
|
||||
pub group_name: Option<String>,
|
||||
pub room_id: Option<String>,
|
||||
pub status: CallStatus,
|
||||
pub created_at: i64,
|
||||
pub answered_at: Option<i64>,
|
||||
pub ended_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Arc<Database>,
|
||||
pub connections: Connections,
|
||||
pub dedup: DedupTracker,
|
||||
pub active_calls: Arc<Mutex<HashMap<String, CallState>>>,
|
||||
pub federation: Option<crate::federation::FederationHandle>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -61,16 +97,18 @@ impl AppState {
|
||||
db: Arc::new(db),
|
||||
connections: Arc::new(Mutex::new(HashMap::new())),
|
||||
dedup: DedupTracker::new(),
|
||||
active_calls: Arc::new(Mutex::new(HashMap::new())),
|
||||
federation: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Try to push a message to a connected client. Returns true if delivered.
|
||||
pub async fn push_to_client(&self, fingerprint: &str, message: &[u8]) -> bool {
|
||||
let conns = self.connections.lock().await;
|
||||
if let Some(senders) = conns.get(fingerprint) {
|
||||
if let Some(devices) = conns.get(fingerprint) {
|
||||
let mut delivered = false;
|
||||
for sender in senders {
|
||||
if sender.send(message.to_vec()).is_ok() {
|
||||
for device in devices {
|
||||
if device.sender.send(message.to_vec()).is_ok() {
|
||||
delivered = true;
|
||||
}
|
||||
}
|
||||
@@ -81,25 +119,127 @@ impl AppState {
|
||||
}
|
||||
|
||||
/// Register a WS connection for a fingerprint.
|
||||
pub async fn register_ws(&self, fingerprint: &str) -> mpsc::UnboundedReceiver<Vec<u8>> {
|
||||
///
|
||||
/// Returns `None` if the per-fingerprint connection cap has been reached.
|
||||
/// On success, returns the assigned device ID and a receiver for push messages.
|
||||
pub async fn register_ws(&self, fingerprint: &str, token: Option<String>) -> Option<(String, mpsc::UnboundedReceiver<Vec<u8>>)> {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let device_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
let mut conns = self.connections.lock().await;
|
||||
conns.entry(fingerprint.to_string()).or_default().push(tx);
|
||||
tracing::info!("WS registered for {} ({} total connections)", fingerprint,
|
||||
conns.values().map(|v| v.len()).sum::<usize>());
|
||||
rx
|
||||
let entry = conns.entry(fingerprint.to_string()).or_default();
|
||||
|
||||
// Clean up closed connections first
|
||||
entry.retain(|d| !d.sender.is_closed());
|
||||
|
||||
if entry.len() >= MAX_WS_PER_FINGERPRINT {
|
||||
tracing::warn!(
|
||||
"WS connection cap reached for {} ({} connections)",
|
||||
fingerprint,
|
||||
entry.len()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
entry.push(DeviceConnection {
|
||||
device_id: device_id.clone(),
|
||||
sender: tx,
|
||||
connected_at: chrono::Utc::now().timestamp(),
|
||||
token,
|
||||
});
|
||||
tracing::info!(
|
||||
"WS registered for {} device={} ({} total)",
|
||||
fingerprint,
|
||||
device_id,
|
||||
conns.values().map(|v| v.len()).sum::<usize>()
|
||||
);
|
||||
Some((device_id, rx))
|
||||
}
|
||||
|
||||
/// Unregister a WS connection.
|
||||
#[allow(dead_code)]
|
||||
pub async fn unregister_ws(&self, fingerprint: &str, sender: &WsSender) {
|
||||
let mut conns = self.connections.lock().await;
|
||||
if let Some(senders) = conns.get_mut(fingerprint) {
|
||||
senders.retain(|s| !s.same_channel(sender));
|
||||
if senders.is_empty() {
|
||||
if let Some(devices) = conns.get_mut(fingerprint) {
|
||||
devices.retain(|d| !d.sender.same_channel(sender));
|
||||
if devices.is_empty() {
|
||||
conns.remove(fingerprint);
|
||||
}
|
||||
}
|
||||
tracing::info!("WS unregistered for {}", fingerprint);
|
||||
}
|
||||
|
||||
/// Try to deliver a message: local push → federation forward → DB queue.
|
||||
/// Returns true if delivered instantly (local or remote).
|
||||
pub async fn deliver_or_queue(&self, to_fp: &str, message: &[u8]) -> bool {
|
||||
// 1. Try local WebSocket push
|
||||
if self.push_to_client(to_fp, message).await {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Try federation forward
|
||||
if let Some(ref federation) = self.federation {
|
||||
if federation.is_remote(to_fp).await {
|
||||
if federation.forward_message(to_fp, message).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Queue in local DB
|
||||
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
||||
let _ = self.db.messages.insert(key.as_bytes(), message);
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a fingerprint has any active WS connections.
|
||||
pub async fn is_online(&self, fingerprint: &str) -> bool {
|
||||
let conns = self.connections.lock().await;
|
||||
conns.get(fingerprint).map(|d| !d.is_empty()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Count active WS connections for a fingerprint (multi-device).
|
||||
pub async fn device_count(&self, fingerprint: &str) -> usize {
|
||||
let conns = self.connections.lock().await;
|
||||
conns.get(fingerprint).map(|d| d.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// List devices for a fingerprint with metadata.
|
||||
pub async fn list_devices(&self, fingerprint: &str) -> Vec<(String, i64)> {
|
||||
let conns = self.connections.lock().await;
|
||||
conns.get(fingerprint)
|
||||
.map(|devices| devices.iter().map(|d| (d.device_id.clone(), d.connected_at)).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Kick a specific device by ID. Returns true if found and kicked.
|
||||
pub async fn kick_device(&self, fingerprint: &str, device_id: &str) -> bool {
|
||||
let mut conns = self.connections.lock().await;
|
||||
if let Some(devices) = conns.get_mut(fingerprint) {
|
||||
let before = devices.len();
|
||||
devices.retain(|d| d.device_id != device_id);
|
||||
let kicked = devices.len() < before;
|
||||
if devices.is_empty() {
|
||||
conns.remove(fingerprint);
|
||||
}
|
||||
kicked
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke all connections for a fingerprint except one device_id.
|
||||
pub async fn revoke_all_except(&self, fingerprint: &str, keep_device_id: &str) -> usize {
|
||||
let mut conns = self.connections.lock().await;
|
||||
if let Some(devices) = conns.get_mut(fingerprint) {
|
||||
let before = devices.len();
|
||||
devices.retain(|d| d.device_id == keep_device_id);
|
||||
let removed = before - devices.len();
|
||||
if devices.is_empty() {
|
||||
conns.remove(fingerprint);
|
||||
}
|
||||
removed
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,10 +474,144 @@ pub fn decrypt_wire_message(
|
||||
"data": hex::encode(&data),
|
||||
}).to_string())
|
||||
}
|
||||
_ => {
|
||||
WireMessage::SenderKeyDistribution {
|
||||
sender_fingerprint,
|
||||
group_name,
|
||||
chain_key,
|
||||
generation,
|
||||
} => {
|
||||
// Return the distribution data so JS can store it
|
||||
Ok(serde_json::json!({
|
||||
"type": "unsupported",
|
||||
"type": "sender_key_distribution",
|
||||
"sender": sender_fingerprint,
|
||||
"group": group_name,
|
||||
"chain_key": hex::encode(chain_key),
|
||||
"generation": generation,
|
||||
}).to_string())
|
||||
}
|
||||
WireMessage::GroupSenderKey {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
group_name,
|
||||
generation,
|
||||
counter,
|
||||
ciphertext,
|
||||
} => {
|
||||
// Return the encrypted group message data so JS can decrypt with stored sender key
|
||||
// JS must call a separate decrypt function with the sender key
|
||||
Ok(serde_json::json!({
|
||||
"type": "group_message",
|
||||
"id": id,
|
||||
"sender": sender_fingerprint,
|
||||
"group": group_name,
|
||||
"generation": generation,
|
||||
"counter": counter,
|
||||
"ciphertext": hex::encode(&ciphertext),
|
||||
}).to_string())
|
||||
}
|
||||
WireMessage::CallSignal {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
signal_type,
|
||||
payload,
|
||||
target,
|
||||
} => {
|
||||
let type_str = match signal_type {
|
||||
warzone_protocol::message::CallSignalType::Offer => "offer",
|
||||
warzone_protocol::message::CallSignalType::Answer => "answer",
|
||||
warzone_protocol::message::CallSignalType::IceCandidate => "ice_candidate",
|
||||
warzone_protocol::message::CallSignalType::Hangup => "hangup",
|
||||
warzone_protocol::message::CallSignalType::Reject => "reject",
|
||||
warzone_protocol::message::CallSignalType::Ringing => "ringing",
|
||||
warzone_protocol::message::CallSignalType::Busy => "busy",
|
||||
};
|
||||
Ok(serde_json::json!({
|
||||
"type": "call_signal",
|
||||
"id": id,
|
||||
"sender": sender_fingerprint,
|
||||
"signal_type": type_str,
|
||||
"payload": payload,
|
||||
"target": target,
|
||||
}).to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt a group message using a stored sender key.
|
||||
///
|
||||
/// Arguments:
|
||||
/// - sender_key_hex: hex-encoded bincode-serialized SenderKey (from sender_key_distribution)
|
||||
/// - sender_fingerprint, group_name, generation, counter, ciphertext_hex: from the group_message JSON
|
||||
///
|
||||
/// Returns JSON: { "text": "...", "sender_key": "updated_hex" }
|
||||
#[wasm_bindgen]
|
||||
pub fn decrypt_group_message(
|
||||
sender_key_hex: &str,
|
||||
sender_fingerprint: &str,
|
||||
group_name: &str,
|
||||
generation: u32,
|
||||
counter: u32,
|
||||
ciphertext_hex: &str,
|
||||
) -> Result<String, JsValue> {
|
||||
use warzone_protocol::sender_keys::{SenderKey, SenderKeyMessage};
|
||||
|
||||
let key_bytes = hex::decode(sender_key_hex)
|
||||
.map_err(|e| JsValue::from_str(&format!("invalid sender key hex: {}", e)))?;
|
||||
let mut sender_key: SenderKey = bincode::deserialize(&key_bytes)
|
||||
.map_err(|e| JsValue::from_str(&format!("deserialize sender key: {}", e)))?;
|
||||
|
||||
let ciphertext = hex::decode(ciphertext_hex)
|
||||
.map_err(|e| JsValue::from_str(&format!("invalid ciphertext hex: {}", e)))?;
|
||||
|
||||
let msg = SenderKeyMessage {
|
||||
sender_fingerprint: sender_fingerprint.to_string(),
|
||||
group_name: group_name.to_string(),
|
||||
generation,
|
||||
counter,
|
||||
ciphertext,
|
||||
};
|
||||
|
||||
let plaintext = sender_key.decrypt(&msg)
|
||||
.map_err(|e| JsValue::from_str(&format!("decrypt: {}", e)))?;
|
||||
|
||||
// Return updated sender key (counter advanced) so JS can persist it
|
||||
let updated_key = bincode::serialize(&sender_key).unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"text": String::from_utf8_lossy(&plaintext),
|
||||
"sender_key": hex::encode(updated_key),
|
||||
}).to_string())
|
||||
}
|
||||
|
||||
/// Create a sender key from a distribution message.
|
||||
///
|
||||
/// Takes the fields from a sender_key_distribution JSON and returns
|
||||
/// a hex-encoded bincode SenderKey that JS should store.
|
||||
#[wasm_bindgen]
|
||||
pub fn create_sender_key_from_distribution(
|
||||
sender_fingerprint: &str,
|
||||
group_name: &str,
|
||||
chain_key_hex: &str,
|
||||
generation: u32,
|
||||
) -> Result<String, JsValue> {
|
||||
use warzone_protocol::sender_keys::SenderKeyDistribution;
|
||||
|
||||
let chain_key_bytes = hex::decode(chain_key_hex)
|
||||
.map_err(|e| JsValue::from_str(&format!("invalid chain key hex: {}", e)))?;
|
||||
let mut chain_key = [0u8; 32];
|
||||
if chain_key_bytes.len() != 32 {
|
||||
return Err(JsValue::from_str("chain key must be 32 bytes"));
|
||||
}
|
||||
chain_key.copy_from_slice(&chain_key_bytes);
|
||||
|
||||
let dist = SenderKeyDistribution {
|
||||
sender_fingerprint: sender_fingerprint.to_string(),
|
||||
group_name: group_name.to_string(),
|
||||
chain_key,
|
||||
generation,
|
||||
};
|
||||
|
||||
let sender_key = dist.into_sender_key();
|
||||
let encoded = bincode::serialize(&sender_key).unwrap_or_default();
|
||||
Ok(hex::encode(encoded))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user