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:
Siavash Sameni
2026-03-28 16:45:58 +04:00
parent 4a4fa9fab4
commit 3e0889e5dc
36 changed files with 5237 additions and 2232 deletions

View File

@@ -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>> {

View 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,
})))
}

View 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,
})))
}

View 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,
}))
}
}
}

View File

@@ -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>,

View File

@@ -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> {

View File

@@ -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)

View File

@@ -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)

View 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 })))
}

View File

@@ -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);
}
}

View 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,
})))
}