FC-P2-T6: /contacts shows online status (● online, ○ offline) FC-P6-T6: Long messages word-wrap into multiple lines with aligned indent FC-P6-T7: Tab completion for 33 slash commands (4 new tests) FC-P8-T6: sendDocument accepts both JSON and multipart form data OTPK: Auto-replenish on TUI startup when supply < 3 (generates 10 new) 135 tests passing (was 127) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
173 lines
5.5 KiB
Rust
173 lines
5.5 KiB
Rust
//! HTTP client for talking to warzone-server.
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use warzone_protocol::prekey::PreKeyBundle;
|
|
|
|
#[derive(Clone)]
|
|
pub struct ServerClient {
|
|
pub base_url: String,
|
|
pub client: reqwest::Client,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct RegisterRequest {
|
|
fingerprint: String,
|
|
bundle: Vec<u8>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
eth_address: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SendRequest {
|
|
to: String,
|
|
from: Option<String>,
|
|
message: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[allow(dead_code)]
|
|
struct BundleResponse {
|
|
fingerprint: String,
|
|
bundle: String, // base64
|
|
}
|
|
|
|
impl ServerClient {
|
|
pub fn new(base_url: &str) -> Self {
|
|
ServerClient {
|
|
base_url: base_url.trim_end_matches('/').to_string(),
|
|
client: reqwest::Client::new(),
|
|
}
|
|
}
|
|
|
|
/// Register our pre-key bundle with the server.
|
|
pub async fn register_bundle(
|
|
&self,
|
|
fingerprint: &str,
|
|
bundle: &PreKeyBundle,
|
|
eth_address: Option<String>,
|
|
) -> Result<()> {
|
|
let encoded =
|
|
bincode::serialize(bundle).context("failed to serialize bundle")?;
|
|
self.client
|
|
.post(format!("{}/v1/keys/register", self.base_url))
|
|
.json(&RegisterRequest {
|
|
fingerprint: fingerprint.to_string(),
|
|
bundle: encoded,
|
|
eth_address,
|
|
})
|
|
.send()
|
|
.await
|
|
.context("failed to register bundle")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Fetch a user's pre-key bundle from the server.
|
|
pub async fn fetch_bundle(&self, fingerprint: &str) -> Result<PreKeyBundle> {
|
|
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
|
let response = self
|
|
.client
|
|
.get(format!(
|
|
"{}/v1/keys/{}",
|
|
self.base_url, fp_clean
|
|
))
|
|
.send()
|
|
.await
|
|
.context("failed to fetch bundle")?;
|
|
|
|
if !response.status().is_success() {
|
|
anyhow::bail!(
|
|
"server returned {} — user {} may not be registered",
|
|
response.status(),
|
|
fingerprint
|
|
);
|
|
}
|
|
|
|
let resp: BundleResponse = response
|
|
.json()
|
|
.await
|
|
.context("failed to parse bundle response")?;
|
|
|
|
let bytes = base64::Engine::decode(
|
|
&base64::engine::general_purpose::STANDARD,
|
|
&resp.bundle,
|
|
)
|
|
.context("failed to decode base64 bundle")?;
|
|
|
|
bincode::deserialize(&bytes).context("failed to deserialize bundle")
|
|
}
|
|
|
|
/// Send an encrypted message to the server for delivery.
|
|
pub async fn send_message(&self, to: &str, from: Option<&str>, message: &[u8]) -> Result<()> {
|
|
let to_clean: String = to.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
|
self.client
|
|
.post(format!("{}/v1/messages/send", self.base_url))
|
|
.json(&SendRequest {
|
|
to: to_clean,
|
|
from: from.map(|f| f.chars().filter(|c| c.is_ascii_hexdigit()).collect()),
|
|
message: message.to_vec(),
|
|
})
|
|
.send()
|
|
.await
|
|
.context("failed to send message")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Check how many one-time pre-keys remain on the server.
|
|
pub async fn otpk_count(&self, fingerprint: &str) -> Result<u64> {
|
|
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
|
let resp: serde_json::Value = self.client
|
|
.get(format!("{}/v1/keys/{}/otpk-count", self.base_url, fp_clean))
|
|
.send()
|
|
.await
|
|
.context("failed to check OTPK count")?
|
|
.json()
|
|
.await
|
|
.context("failed to parse OTPK count")?;
|
|
Ok(resp.get("count").and_then(|v| v.as_u64()).unwrap_or(0))
|
|
}
|
|
|
|
/// Upload additional one-time pre-keys.
|
|
pub async fn replenish_otpks(&self, fingerprint: &str, keys: Vec<(u32, [u8; 32])>) -> Result<()> {
|
|
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
|
let otpks: Vec<serde_json::Value> = keys.iter().map(|(id, pubkey)| {
|
|
serde_json::json!({"id": id, "public_key": hex::encode(pubkey)})
|
|
}).collect();
|
|
self.client
|
|
.post(format!("{}/v1/keys/replenish", self.base_url))
|
|
.json(&serde_json::json!({"fingerprint": fp_clean, "one_time_pre_keys": otpks}))
|
|
.send()
|
|
.await
|
|
.context("failed to replenish OTPKs")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Poll for messages addressed to us.
|
|
pub async fn poll_messages(&self, fingerprint: &str) -> Result<Vec<Vec<u8>>> {
|
|
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
|
let resp: Vec<String> = self
|
|
.client
|
|
.get(format!(
|
|
"{}/v1/messages/poll/{}",
|
|
self.base_url, fp_clean
|
|
))
|
|
.send()
|
|
.await
|
|
.context("failed to poll messages")?
|
|
.json()
|
|
.await
|
|
.context("failed to parse poll response")?;
|
|
|
|
let mut messages = Vec::new();
|
|
for b64 in resp {
|
|
if let Ok(bytes) = base64::Engine::decode(
|
|
&base64::engine::general_purpose::STANDARD,
|
|
&b64,
|
|
) {
|
|
messages.push(bytes);
|
|
}
|
|
}
|
|
Ok(messages)
|
|
}
|
|
}
|