Group chat with E2E encryption for both web and CLI clients
Server: - POST /v1/groups/create — create named group - POST /v1/groups/:name/join — join group - GET /v1/groups/:name — get group info + member list - GET /v1/groups — list all groups - POST /v1/groups/:name/send — fan-out encrypted messages to members - Groups stored in sled, members tracked by fingerprint Web client: - /gcreate <name> — create group - /gjoin <name> — join group - /g <name> — switch to group chat mode - /glist — list all groups - /dm — switch back to DM mode - Group messages encrypted per-member (ECDH + AES-GCM for each) - Group tag shown on received messages: "sender [groupname]" CLI TUI client: - Same commands: /gcreate, /gjoin, /g, /glist, /dm - Group messages encrypted per-member (X3DH + Double Ratchet for each) - Automatic X3DH key exchange with new group members on first message - Sessions established and persisted per-member Architecture: - Client-side fan-out encryption: message encrypted N times (once per member) - Server stores one copy per recipient in their message queue - Reuses existing 1:1 encryption — no new crypto primitives needed - Works for groups ≤ 50 members (per DESIGN.md) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ use warzone_protocol::prekey::PreKeyBundle;
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ServerClient {
|
pub struct ServerClient {
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
client: reqwest::Client,
|
pub client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|||||||
@@ -192,9 +192,50 @@ impl App {
|
|||||||
self.peer_fp = Some(fp);
|
self.peer_fp = Some(fp);
|
||||||
return;
|
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();
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Switched to group #{}", name),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
self.peer_fp = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text == "/glist" {
|
||||||
|
self.group_list(client).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Send message
|
// Send message (group or DM)
|
||||||
let peer = match &self.peer_fp {
|
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(),
|
Some(p) => p.clone(),
|
||||||
None => {
|
None => {
|
||||||
self.add_message(ChatLine {
|
self.add_message(ChatLine {
|
||||||
@@ -328,6 +369,183 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
} else {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn 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 });
|
||||||
|
} 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
} 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }); return; }
|
||||||
|
},
|
||||||
|
Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }); 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 {
|
||||||
|
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 {
|
||||||
|
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 });
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normfp(fp: &str) -> String {
|
||||||
|
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll for incoming messages in the background.
|
/// Poll for incoming messages in the background.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use anyhow::Result;
|
|||||||
pub struct Database {
|
pub struct Database {
|
||||||
pub keys: sled::Tree,
|
pub keys: sled::Tree,
|
||||||
pub messages: sled::Tree,
|
pub messages: sled::Tree,
|
||||||
|
pub groups: sled::Tree,
|
||||||
_db: sled::Db,
|
_db: sled::Db,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,9 +12,11 @@ impl Database {
|
|||||||
let db = sled::open(data_dir)?;
|
let db = sled::open(data_dir)?;
|
||||||
let keys = db.open_tree("keys")?;
|
let keys = db.open_tree("keys")?;
|
||||||
let messages = db.open_tree("messages")?;
|
let messages = db.open_tree("messages")?;
|
||||||
|
let groups = db.open_tree("groups")?;
|
||||||
Ok(Database {
|
Ok(Database {
|
||||||
keys,
|
keys,
|
||||||
messages,
|
messages,
|
||||||
|
groups,
|
||||||
_db: db,
|
_db: db,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
193
warzone/crates/warzone-server/src/routes/groups.rs
Normal file
193
warzone/crates/warzone-server/src/routes/groups.rs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::errors::AppResult;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/groups", get(list_groups))
|
||||||
|
.route("/groups/create", post(create_group))
|
||||||
|
.route("/groups/:name", get(get_group))
|
||||||
|
.route("/groups/:name/join", post(join_group))
|
||||||
|
.route("/groups/:name/send", post(send_to_group))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
struct GroupInfo {
|
||||||
|
name: String,
|
||||||
|
creator: String,
|
||||||
|
members: Vec<String>, // fingerprints
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateRequest {
|
||||||
|
name: String,
|
||||||
|
creator: String, // fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct JoinRequest {
|
||||||
|
fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group message: the client sends one ciphertext per member.
|
||||||
|
/// Server fans out each entry to the respective member's message queue.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GroupSendRequest {
|
||||||
|
from: String,
|
||||||
|
/// Each entry is an encrypted message destined for one member.
|
||||||
|
/// The client encrypts separately for each recipient.
|
||||||
|
messages: Vec<MemberMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct MemberMessage {
|
||||||
|
to: String, // member fingerprint
|
||||||
|
message: Vec<u8>, // encrypted payload (same format as 1:1 messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_fp(fp: &str) -> String {
|
||||||
|
fp.chars()
|
||||||
|
.filter(|c| c.is_ascii_hexdigit())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_group(db: &sled::Tree, name: &str) -> Option<GroupInfo> {
|
||||||
|
db.get(name.as_bytes())
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.and_then(|data| serde_json::from_slice(&data).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_group(db: &sled::Tree, group: &GroupInfo) -> anyhow::Result<()> {
|
||||||
|
let data = serde_json::to_vec(group)?;
|
||||||
|
db.insert(group.name.as_bytes(), data)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_group(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<CreateRequest>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let name = req.name.trim().to_lowercase();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Ok(Json(serde_json::json!({ "error": "name required" })));
|
||||||
|
}
|
||||||
|
|
||||||
|
if load_group(&state.db.groups, &name).is_some() {
|
||||||
|
return Ok(Json(serde_json::json!({ "error": "group already exists" })));
|
||||||
|
}
|
||||||
|
|
||||||
|
let creator = normalize_fp(&req.creator);
|
||||||
|
let group = GroupInfo {
|
||||||
|
name: name.clone(),
|
||||||
|
creator: creator.clone(),
|
||||||
|
members: vec![creator],
|
||||||
|
};
|
||||||
|
save_group(&state.db.groups, &group)?;
|
||||||
|
tracing::info!("Group '{}' created", name);
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true, "name": name })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn join_group(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
Json(req): Json<JoinRequest>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let fp = normalize_fp(&req.fingerprint);
|
||||||
|
|
||||||
|
let mut group = match load_group(&state.db.groups, &name) {
|
||||||
|
Some(g) => g,
|
||||||
|
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !group.members.contains(&fp) {
|
||||||
|
group.members.push(fp.clone());
|
||||||
|
save_group(&state.db.groups, &group)?;
|
||||||
|
tracing::info!("{} joined group '{}'", fp, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true, "members": group.members.len() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_group(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
match load_group(&state.db.groups, &name) {
|
||||||
|
Some(group) => Ok(Json(serde_json::json!({
|
||||||
|
"name": group.name,
|
||||||
|
"creator": group.creator,
|
||||||
|
"members": group.members,
|
||||||
|
"count": group.members.len(),
|
||||||
|
}))),
|
||||||
|
None => Ok(Json(serde_json::json!({ "error": "group not found" }))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_groups(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let groups: Vec<serde_json::Value> = state
|
||||||
|
.db
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
item.ok().and_then(|(_, data)| {
|
||||||
|
serde_json::from_slice::<GroupInfo>(&data).ok().map(|g| {
|
||||||
|
serde_json::json!({
|
||||||
|
"name": g.name,
|
||||||
|
"members": g.members.len(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(serde_json::json!({ "groups": groups })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fan-out: client sends per-member encrypted messages, server puts each
|
||||||
|
/// in the respective member's queue. This reuses the existing message
|
||||||
|
/// queue infrastructure — group messages look like 1:1 messages to the
|
||||||
|
/// recipient, but with a group tag.
|
||||||
|
async fn send_to_group(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
Json(req): Json<GroupSendRequest>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let group = match load_group(&state.db.groups, &name) {
|
||||||
|
Some(g) => g,
|
||||||
|
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let from = normalize_fp(&req.from);
|
||||||
|
if !group.members.contains(&from) {
|
||||||
|
return Ok(Json(serde_json::json!({ "error": "not a member of this group" })));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut delivered = 0;
|
||||||
|
for msg in &req.messages {
|
||||||
|
let to = normalize_fp(&msg.to);
|
||||||
|
if group.members.contains(&to) {
|
||||||
|
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
|
||||||
|
state.db.messages.insert(key.as_bytes(), msg.message.as_slice())?;
|
||||||
|
delivered += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Group '{}': {} sent {} messages to {} members",
|
||||||
|
name,
|
||||||
|
from,
|
||||||
|
delivered,
|
||||||
|
group.members.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true, "delivered": delivered })))
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod groups;
|
||||||
mod health;
|
mod health;
|
||||||
mod keys;
|
mod keys;
|
||||||
mod messages;
|
mod messages;
|
||||||
@@ -12,6 +13,7 @@ pub fn router() -> Router<AppState> {
|
|||||||
.merge(health::routes())
|
.merge(health::routes())
|
||||||
.merge(keys::routes())
|
.merge(keys::routes())
|
||||||
.merge(messages::routes())
|
.merge(messages::routes())
|
||||||
|
.merge(groups::routes())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web UI router (served at root, outside /v1)
|
/// Web UI router (served at root, outside /v1)
|
||||||
|
|||||||
@@ -295,7 +295,9 @@ async function pollMessages() {
|
|||||||
const aesKey = await fetchPeerKey(env.from);
|
const aesKey = await fetchPeerKey(env.from);
|
||||||
const ct = fromHex(env.ciphertext);
|
const ct = fromHex(env.ciphertext);
|
||||||
const text = await aesDecrypt(aesKey, ct);
|
const text = await aesDecrypt(aesKey, ct);
|
||||||
addMsg(formatFP(fromHex(env.from)).slice(0, 19), text, false);
|
const fromLabel = formatFP(fromHex(env.from)).slice(0, 19);
|
||||||
|
const groupTag = env.group ? ' [' + env.group + ']' : '';
|
||||||
|
addMsg(fromLabel + groupTag, text, false);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} catch(e) { /* not JSON, might be CLI bincode */ }
|
} catch(e) { /* not JSON, might be CLI bincode */ }
|
||||||
@@ -364,6 +366,8 @@ async function doRecover() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentGroup = null; // if set, messages go to group
|
||||||
|
|
||||||
async function enterChat() {
|
async function enterChat() {
|
||||||
document.getElementById('setup').classList.remove('active');
|
document.getElementById('setup').classList.remove('active');
|
||||||
document.getElementById('chat').classList.add('active');
|
document.getElementById('chat').classList.add('active');
|
||||||
@@ -373,18 +377,103 @@ async function enterChat() {
|
|||||||
await registerKey();
|
await registerKey();
|
||||||
addSys('Identity loaded: ' + myFingerprint);
|
addSys('Identity loaded: ' + myFingerprint);
|
||||||
addSys('Key registered with server');
|
addSys('Key registered with server');
|
||||||
addSys('Paste a peer fingerprint above and start chatting');
|
addSys('DM: paste peer fingerprint above');
|
||||||
addSys('Commands: /info, /clear, /quit');
|
addSys('Groups: /gcreate <name> · /gjoin <name> · /g <name> · /glist');
|
||||||
|
addSys('Other: /info · /clear · /dm (switch back to DM mode)');
|
||||||
|
|
||||||
// Restore saved peer
|
|
||||||
const savedPeer = localStorage.getItem('wz-peer');
|
const savedPeer = localStorage.getItem('wz-peer');
|
||||||
if (savedPeer) $peerInput.value = savedPeer;
|
if (savedPeer) $peerInput.value = savedPeer;
|
||||||
|
|
||||||
// Start polling
|
|
||||||
pollTimer = setInterval(pollMessages, 2000);
|
pollTimer = setInterval(pollMessages, 2000);
|
||||||
$input.focus();
|
$input.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Group helpers ──
|
||||||
|
|
||||||
|
async function groupCreate(name) {
|
||||||
|
const resp = await fetch(SERVER + '/v1/groups/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, creator: normFP(myFingerprint) })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) { addSys('Error: ' + data.error); return; }
|
||||||
|
addSys('Group "' + name + '" created. Join with /gjoin ' + name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function groupJoin(name) {
|
||||||
|
const resp = await fetch(SERVER + '/v1/groups/' + name + '/join', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fingerprint: normFP(myFingerprint) })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) { addSys('Error: ' + data.error); return; }
|
||||||
|
addSys('Joined group "' + name + '" (' + data.members + ' members)');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function groupSwitch(name) {
|
||||||
|
const resp = await fetch(SERVER + '/v1/groups/' + name);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) { addSys('Error: ' + data.error); return; }
|
||||||
|
currentGroup = name;
|
||||||
|
$peerInput.value = '#' + name;
|
||||||
|
addSys('Switched to group "' + name + '" (' + data.count + ' members: ' + data.members.map(m => m.slice(0,8)).join(', ') + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function groupList() {
|
||||||
|
const resp = await fetch(SERVER + '/v1/groups');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.groups || data.groups.length === 0) { addSys('No groups'); return; }
|
||||||
|
for (const g of data.groups) {
|
||||||
|
addSys(' #' + g.name + ' (' + g.members + ' members)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendToGroup(groupName, text) {
|
||||||
|
// Get member list
|
||||||
|
const resp = await fetch(SERVER + '/v1/groups/' + groupName);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) { addSys('Error: ' + data.error); return; }
|
||||||
|
|
||||||
|
const myFP = normFP(myFingerprint);
|
||||||
|
const members = data.members.filter(m => m !== myFP);
|
||||||
|
if (members.length === 0) { addSys('No other members in group'); return; }
|
||||||
|
|
||||||
|
// Encrypt for each member
|
||||||
|
const messages = [];
|
||||||
|
for (const memberFP of members) {
|
||||||
|
try {
|
||||||
|
const aesKey = await fetchPeerKey(memberFP);
|
||||||
|
const encrypted = await aesEncrypt(aesKey, text);
|
||||||
|
const envelope = JSON.stringify({
|
||||||
|
type: 'web',
|
||||||
|
from: myFP,
|
||||||
|
group: groupName,
|
||||||
|
ciphertext: toHex(encrypted)
|
||||||
|
});
|
||||||
|
messages.push({
|
||||||
|
to: memberFP,
|
||||||
|
message: Array.from(new TextEncoder().encode(envelope))
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
addSys('Failed to encrypt for ' + memberFP.slice(0,8) + ': ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
|
||||||
|
await fetch(SERVER + '/v1/groups/' + groupName + '/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ from: myFP, messages })
|
||||||
|
});
|
||||||
|
|
||||||
|
addMsg(myFingerprint.slice(0, 19) + ' [' + groupName + ']', text, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Send handler ──
|
||||||
|
|
||||||
async function doSend() {
|
async function doSend() {
|
||||||
const text = $input.value.trim();
|
const text = $input.value.trim();
|
||||||
$input.value = '';
|
$input.value = '';
|
||||||
@@ -395,9 +484,26 @@ async function doSend() {
|
|||||||
if (text === '/info') { addSys('Fingerprint: ' + myFingerprint); return; }
|
if (text === '/info') { addSys('Fingerprint: ' + myFingerprint); return; }
|
||||||
if (text === '/clear') { $messages.innerHTML = ''; return; }
|
if (text === '/clear') { $messages.innerHTML = ''; return; }
|
||||||
if (text === '/quit') { window.close(); return; }
|
if (text === '/quit') { window.close(); return; }
|
||||||
|
if (text === '/glist') { await groupList(); return; }
|
||||||
|
if (text === '/dm') { currentGroup = null; addSys('Switched to DM mode'); $peerInput.value = localStorage.getItem('wz-peer') || ''; return; }
|
||||||
|
|
||||||
|
if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; }
|
||||||
|
if (text.startsWith('/gjoin ')) { await groupJoin(text.slice(7).trim()); return; }
|
||||||
|
if (text.startsWith('/g ')) { await groupSwitch(text.slice(3).trim()); return; }
|
||||||
|
|
||||||
|
// Send to group or DM
|
||||||
|
if (currentGroup) {
|
||||||
|
try {
|
||||||
|
await sendToGroup(currentGroup, text);
|
||||||
|
} catch(e) {
|
||||||
|
addSys('Group send failed: ' + e.message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DM
|
||||||
const peer = $peerInput.value.trim();
|
const peer = $peerInput.value.trim();
|
||||||
if (!peer) { addSys('Set a peer fingerprint first'); return; }
|
if (!peer || peer.startsWith('#')) { addSys('Set a peer fingerprint or use /g <group>'); return; }
|
||||||
localStorage.setItem('wz-peer', peer);
|
localStorage.setItem('wz-peer', peer);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user